[FEAT] Add all frontend pages - complete UI for NIS2 platform
- risks.html: Risk register with 5x5 matrix heatmap, treatments, AI suggest - incidents.html: Incident management with NIS2 Art.23 timeline (24h/72h/30d) - policies.html: Policy management with templates, approval workflow, AI generate - supply-chain.html: Supplier registry with 10-question security assessment - training.html: Courses, assignments, compliance status tracking - assets.html: Asset inventory with dependency mapping - reports.html: Compliance report, controls, audit log, ISO 27001 mapping - settings.html: Organization, profile, members, security settings - admin/index.html: Platform admin dashboard with stats - admin/organizations.html: Organization management for super_admin - admin/users.html: User management for super_admin Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,375 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pannello Amministrazione - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="/nis2/css/style.css">
|
||||||
|
<style>
|
||||||
|
.admin-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.admin-stat-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
padding: 20px;
|
||||||
|
transition: box-shadow var(--transition), transform var(--transition);
|
||||||
|
}
|
||||||
|
.admin-stat-card:hover {
|
||||||
|
box-shadow: var(--card-shadow-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.admin-stat-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.admin-stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--gray-900);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.admin-stat-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.admin-stat-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.admin-stat-icon.blue { background: var(--primary-bg); color: var(--primary); }
|
||||||
|
.admin-stat-icon.green { background: var(--secondary-bg); color: var(--secondary); }
|
||||||
|
.admin-stat-icon.orange { background: var(--warning-bg); color: #d97706; }
|
||||||
|
.admin-stat-icon.red { background: var(--danger-bg); color: var(--danger); }
|
||||||
|
.admin-stat-icon.purple { background: #f3e8ff; color: #7c3aed; }
|
||||||
|
.chart-container {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.bar-chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 32px;
|
||||||
|
height: 200px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 2px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
.bar-chart-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.bar-chart-bar {
|
||||||
|
width: 60px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
transition: height 0.8s ease;
|
||||||
|
min-height: 4px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.bar-chart-bar.free { background: var(--gray-400); }
|
||||||
|
.bar-chart-bar.professional { background: var(--primary); }
|
||||||
|
.bar-chart-bar.enterprise { background: #7c3aed; }
|
||||||
|
.bar-chart-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gray-700);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.bar-chart-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-top: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.quick-link-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.quick-link-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-bg);
|
||||||
|
color: var(--primary);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--card-shadow-hover);
|
||||||
|
}
|
||||||
|
.quick-link-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: var(--primary-bg);
|
||||||
|
color: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.quick-link-icon svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
.quick-link-info h4 {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-800);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.quick-link-info p {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
.quick-link-card:hover .quick-link-info h4 {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.quick-links-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.admin-stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.quick-links-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.bar-chart {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.bar-chart-bar {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar" id="sidebar"></aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="content-header">
|
||||||
|
<h2>Pannello Amministrazione</h2>
|
||||||
|
<div class="content-header-actions">
|
||||||
|
<span class="badge badge-danger">Super Admin</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-body">
|
||||||
|
<!-- Stats Row 1 -->
|
||||||
|
<div class="admin-stats-grid" id="stats-row-1">
|
||||||
|
<div class="admin-stat-card">
|
||||||
|
<div class="admin-stat-icon blue">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-label">Totale Organizzazioni</div>
|
||||||
|
<div class="admin-stat-value" id="stat-total-orgs">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-card">
|
||||||
|
<div class="admin-stat-icon green">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-label">Organizzazioni Attive</div>
|
||||||
|
<div class="admin-stat-value" id="stat-active-orgs">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-card">
|
||||||
|
<div class="admin-stat-icon blue">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-label">Totale Utenti</div>
|
||||||
|
<div class="admin-stat-value" id="stat-total-users">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-card">
|
||||||
|
<div class="admin-stat-icon green">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-label">Utenti Attivi</div>
|
||||||
|
<div class="admin-stat-value" id="stat-active-users">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Row 2 -->
|
||||||
|
<div class="admin-stats-grid" id="stats-row-2">
|
||||||
|
<div class="admin-stat-card">
|
||||||
|
<div class="admin-stat-icon purple">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-label">Assessment Completati</div>
|
||||||
|
<div class="admin-stat-value" id="stat-assessments">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-card">
|
||||||
|
<div class="admin-stat-icon orange">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-label">Incidenti Aperti</div>
|
||||||
|
<div class="admin-stat-value" id="stat-incidents">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-card">
|
||||||
|
<div class="admin-stat-icon red">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-label">Totale Rischi</div>
|
||||||
|
<div class="admin-stat-value" id="stat-risks">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-card">
|
||||||
|
<div class="admin-stat-icon purple">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-label">Interazioni AI</div>
|
||||||
|
<div class="admin-stat-value" id="stat-ai">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plans Distribution -->
|
||||||
|
<div class="card mb-24">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Distribuzione Piani</h3>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="bar-chart" id="plans-chart">
|
||||||
|
<div class="spinner" style="margin:40px auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Links -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Gestione Piattaforma</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="quick-links-grid">
|
||||||
|
<a href="/nis2/admin/organizations.html" class="quick-link-card">
|
||||||
|
<div class="quick-link-icon">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="quick-link-info">
|
||||||
|
<h4>Gestisci Organizzazioni</h4>
|
||||||
|
<p>Visualizza e gestisci tutte le organizzazioni registrate sulla piattaforma.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/nis2/admin/users.html" class="quick-link-card">
|
||||||
|
<div class="quick-link-icon">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="quick-link-info">
|
||||||
|
<h4>Gestisci Utenti</h4>
|
||||||
|
<p>Visualizza e gestisci tutti gli utenti registrati sulla piattaforma.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/nis2/js/api.js"></script>
|
||||||
|
<script src="/nis2/js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── Auth check ───────────────────────────────────────────
|
||||||
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
loadSidebar();
|
||||||
|
|
||||||
|
// ── Admin check ──────────────────────────────────────────
|
||||||
|
(async function checkAdmin() {
|
||||||
|
try {
|
||||||
|
const me = await api.getMe();
|
||||||
|
if (!me.success || !me.data || me.data.role !== 'super_admin') {
|
||||||
|
showNotification('Accesso non autorizzato. Solo gli amministratori possono accedere a questa pagina.', 'error');
|
||||||
|
setTimeout(() => { window.location.href = '/nis2/dashboard.html'; }, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadAdminStats();
|
||||||
|
} catch (e) {
|
||||||
|
window.location.href = '/nis2/dashboard.html';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Load Stats ───────────────────────────────────────────
|
||||||
|
async function loadAdminStats() {
|
||||||
|
try {
|
||||||
|
const result = await api.request('GET', '/admin/stats');
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const d = result.data;
|
||||||
|
|
||||||
|
document.getElementById('stat-total-orgs').textContent = d.total_organizations ?? 0;
|
||||||
|
document.getElementById('stat-active-orgs').textContent = d.active_organizations ?? 0;
|
||||||
|
document.getElementById('stat-total-users').textContent = d.total_users ?? 0;
|
||||||
|
document.getElementById('stat-active-users').textContent = d.active_users ?? 0;
|
||||||
|
document.getElementById('stat-assessments').textContent = d.completed_assessments ?? 0;
|
||||||
|
document.getElementById('stat-incidents').textContent = d.open_incidents ?? 0;
|
||||||
|
document.getElementById('stat-risks').textContent = d.total_risks ?? 0;
|
||||||
|
document.getElementById('stat-ai').textContent = d.ai_interactions ?? 0;
|
||||||
|
|
||||||
|
renderPlansChart(d.plans_distribution || []);
|
||||||
|
} else {
|
||||||
|
showNotification('Impossibile caricare le statistiche.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Errore caricamento stats:', e);
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plans Chart ──────────────────────────────────────────
|
||||||
|
function renderPlansChart(distribution) {
|
||||||
|
const container = document.getElementById('plans-chart');
|
||||||
|
|
||||||
|
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };
|
||||||
|
const plans = { free: 0, professional: 0, enterprise: 0 };
|
||||||
|
|
||||||
|
distribution.forEach(p => {
|
||||||
|
const key = (p.subscription_plan || 'free').toLowerCase();
|
||||||
|
if (plans.hasOwnProperty(key)) {
|
||||||
|
plans[key] = parseInt(p.count) || 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxVal = Math.max(1, ...Object.values(plans));
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (const [key, count] of Object.entries(plans)) {
|
||||||
|
const heightPct = (count / maxVal) * 100;
|
||||||
|
html += `
|
||||||
|
<div class="bar-chart-item">
|
||||||
|
<div class="bar-chart-value">${count}</div>
|
||||||
|
<div class="bar-chart-bar ${key}" style="height:${Math.max(heightPct, 3)}%;"></div>
|
||||||
|
<div class="bar-chart-label">${planLabels[key]}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Gestione Organizzazioni - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="/nis2/css/style.css">
|
||||||
|
<style>
|
||||||
|
.entity-type-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.entity-type-badge.essential { background: var(--danger-bg); color: var(--danger); }
|
||||||
|
.entity-type-badge.important { background: var(--warning-bg); color: #a16207; }
|
||||||
|
.entity-type-badge.not_applicable { background: var(--gray-100); color: var(--gray-600); }
|
||||||
|
.plan-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.plan-badge.free { background: var(--gray-100); color: var(--gray-600); }
|
||||||
|
.plan-badge.professional { background: var(--primary-bg); color: var(--primary); }
|
||||||
|
.plan-badge.enterprise { background: #f3e8ff; color: #7c3aed; }
|
||||||
|
.status-dot {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.status-dot::before {
|
||||||
|
content: '';
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-dot.active::before { background: var(--secondary); }
|
||||||
|
.status-dot.inactive::before { background: var(--gray-400); }
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--gray-100);
|
||||||
|
background: var(--gray-50);
|
||||||
|
}
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.score-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.score-inline .score-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.admin-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.admin-breadcrumb a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.admin-breadcrumb a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.admin-breadcrumb svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar" id="sidebar"></aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="content-header">
|
||||||
|
<h2>Gestione Organizzazioni</h2>
|
||||||
|
<div class="content-header-actions">
|
||||||
|
<span class="badge badge-danger">Super Admin</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-body">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="admin-breadcrumb">
|
||||||
|
<a href="/nis2/admin/index.html">Amministrazione</a>
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||||
|
<span>Organizzazioni</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body" style="padding:0;">
|
||||||
|
<div id="orgs-container">
|
||||||
|
<div class="spinner" style="margin:60px auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/nis2/js/api.js"></script>
|
||||||
|
<script src="/nis2/js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── Auth check ───────────────────────────────────────────
|
||||||
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
loadSidebar();
|
||||||
|
|
||||||
|
let currentPage = 1;
|
||||||
|
|
||||||
|
// ── Admin check ──────────────────────────────────────────
|
||||||
|
(async function checkAdmin() {
|
||||||
|
try {
|
||||||
|
const me = await api.getMe();
|
||||||
|
if (!me.success || !me.data || me.data.role !== 'super_admin') {
|
||||||
|
showNotification('Accesso non autorizzato.', 'error');
|
||||||
|
setTimeout(() => { window.location.href = '/nis2/dashboard.html'; }, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadOrganizations(1);
|
||||||
|
} catch (e) {
|
||||||
|
window.location.href = '/nis2/dashboard.html';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Mappature ────────────────────────────────────────────
|
||||||
|
const sectorLabels = {
|
||||||
|
energy: 'Energia', transport: 'Trasporti', banking: 'Banche',
|
||||||
|
health: 'Sanita\'', water: 'Acqua', digital_infra: 'Infrastrutture Digitali',
|
||||||
|
public_admin: 'Pubblica Amministrazione', manufacturing: 'Manifatturiero',
|
||||||
|
postal: 'Servizi Postali', chemical: 'Chimico', food: 'Alimentare',
|
||||||
|
waste: 'Rifiuti', ict_services: 'Servizi ICT',
|
||||||
|
digital_providers: 'Provider Digitali', space: 'Spazio',
|
||||||
|
research: 'Ricerca', other: 'Altro'
|
||||||
|
};
|
||||||
|
|
||||||
|
const entityTypeLabels = {
|
||||||
|
essential: 'Essenziale',
|
||||||
|
important: 'Importante',
|
||||||
|
not_applicable: 'N/A'
|
||||||
|
};
|
||||||
|
|
||||||
|
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };
|
||||||
|
|
||||||
|
// ── Caricamento ──────────────────────────────────────────
|
||||||
|
async function loadOrganizations(page) {
|
||||||
|
const container = document.getElementById('orgs-container');
|
||||||
|
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
|
||||||
|
currentPage = page;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.request('GET', '/admin/organizations?page=' + page);
|
||||||
|
if (result.success) {
|
||||||
|
renderOrganizations(result.data || [], result.pagination || {});
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h4>Errore nel caricamento</h4>
|
||||||
|
<p>${escapeHtml(result.message || 'Errore sconosciuto')}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOrganizations(orgs, pagination) {
|
||||||
|
const container = document.getElementById('orgs-container');
|
||||||
|
|
||||||
|
if (!orgs || orgs.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/></svg>
|
||||||
|
<h4>Nessuna organizzazione trovata</h4>
|
||||||
|
<p>Le organizzazioni registrate appariranno qui.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Settore</th>
|
||||||
|
<th>Tipo Entita'</th>
|
||||||
|
<th>Membri</th>
|
||||||
|
<th>Ultimo Score</th>
|
||||||
|
<th>Piano</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th>Data Creazione</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
orgs.forEach(org => {
|
||||||
|
const entityType = org.entity_type || 'not_applicable';
|
||||||
|
const sector = sectorLabels[org.sector] || org.sector || '-';
|
||||||
|
const entityLabel = entityTypeLabels[entityType] || entityType;
|
||||||
|
const plan = (org.subscription_plan || 'free').toLowerCase();
|
||||||
|
const planLabel = planLabels[plan] || plan;
|
||||||
|
const isActive = org.is_active == 1 || org.is_active === true;
|
||||||
|
const score = org.last_score;
|
||||||
|
let scoreHtml = '<span class="text-muted">-</span>';
|
||||||
|
|
||||||
|
if (score !== null && score !== undefined) {
|
||||||
|
const scoreColor = getScoreColor(score);
|
||||||
|
scoreHtml = `
|
||||||
|
<span class="score-inline">
|
||||||
|
<span class="score-dot" style="background:${scoreColor}"></span>
|
||||||
|
${Math.round(score)}%
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(org.name || '-')}</strong></td>
|
||||||
|
<td>${escapeHtml(sector)}</td>
|
||||||
|
<td><span class="entity-type-badge ${entityType}">${escapeHtml(entityLabel)}</span></td>
|
||||||
|
<td>${org.member_count ?? '-'}</td>
|
||||||
|
<td>${scoreHtml}</td>
|
||||||
|
<td><span class="plan-badge ${plan}">${escapeHtml(planLabel)}</span></td>
|
||||||
|
<td><span class="status-dot ${isActive ? 'active' : 'inactive'}">${isActive ? 'Attiva' : 'Inattiva'}</span></td>
|
||||||
|
<td>${formatDate(org.created_at)}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
|
||||||
|
// Paginazione
|
||||||
|
const total = pagination.total || orgs.length;
|
||||||
|
const perPage = pagination.per_page || 20;
|
||||||
|
const page = pagination.page || currentPage;
|
||||||
|
const totalPages = Math.ceil(total / perPage);
|
||||||
|
const start = ((page - 1) * perPage) + 1;
|
||||||
|
const end = Math.min(page * perPage, total);
|
||||||
|
|
||||||
|
if (totalPages > 1) {
|
||||||
|
html += `
|
||||||
|
<div class="pagination">
|
||||||
|
<div class="pagination-info">
|
||||||
|
Visualizzati ${start}-${end} di ${total} organizzazioni
|
||||||
|
</div>
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="loadOrganizations(${page - 1})" ${page <= 1 ? 'disabled' : ''}>
|
||||||
|
« Precedente
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="loadOrganizations(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
|
||||||
|
Successivo »
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else if (total > 0) {
|
||||||
|
html += `
|
||||||
|
<div class="pagination">
|
||||||
|
<div class="pagination-info">
|
||||||
|
Totale: ${total} organizzazion${total === 1 ? 'e' : 'i'}
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Gestione Utenti - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="/nis2/css/style.css">
|
||||||
|
<style>
|
||||||
|
.role-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.role-badge.super_admin { background: var(--danger-bg); color: var(--danger); }
|
||||||
|
.role-badge.org_admin { background: var(--primary-bg); color: var(--primary); }
|
||||||
|
.role-badge.compliance_manager { background: var(--secondary-bg); color: #15803d; }
|
||||||
|
.role-badge.board_member { background: #f3e8ff; color: #7c3aed; }
|
||||||
|
.role-badge.auditor { background: var(--info-bg); color: var(--primary); }
|
||||||
|
.role-badge.employee { background: var(--gray-100); color: var(--gray-600); }
|
||||||
|
.active-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.active-toggle input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.toggle-slider {
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--gray-300);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.toggle-slider::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.active-toggle input:checked + .toggle-slider {
|
||||||
|
background: var(--secondary);
|
||||||
|
}
|
||||||
|
.active-toggle input:checked + .toggle-slider::after {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--gray-100);
|
||||||
|
background: var(--gray-50);
|
||||||
|
}
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.org-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.org-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--gray-100);
|
||||||
|
color: var(--gray-600);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.admin-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.admin-breadcrumb a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.admin-breadcrumb a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.admin-breadcrumb svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar" id="sidebar"></aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="content-header">
|
||||||
|
<h2>Gestione Utenti</h2>
|
||||||
|
<div class="content-header-actions">
|
||||||
|
<span class="badge badge-danger">Super Admin</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-body">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="admin-breadcrumb">
|
||||||
|
<a href="/nis2/admin/index.html">Amministrazione</a>
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||||
|
<span>Utenti</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body" style="padding:0;">
|
||||||
|
<div id="users-container">
|
||||||
|
<div class="spinner" style="margin:60px auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/nis2/js/api.js"></script>
|
||||||
|
<script src="/nis2/js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── Auth check ───────────────────────────────────────────
|
||||||
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
loadSidebar();
|
||||||
|
|
||||||
|
let currentPage = 1;
|
||||||
|
|
||||||
|
// ── Admin check ──────────────────────────────────────────
|
||||||
|
(async function checkAdmin() {
|
||||||
|
try {
|
||||||
|
const me = await api.getMe();
|
||||||
|
if (!me.success || !me.data || me.data.role !== 'super_admin') {
|
||||||
|
showNotification('Accesso non autorizzato.', 'error');
|
||||||
|
setTimeout(() => { window.location.href = '/nis2/dashboard.html'; }, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadUsers(1);
|
||||||
|
} catch (e) {
|
||||||
|
window.location.href = '/nis2/dashboard.html';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Mappature ────────────────────────────────────────────
|
||||||
|
const roleLabels = {
|
||||||
|
super_admin: 'Super Admin',
|
||||||
|
org_admin: 'Amministratore',
|
||||||
|
compliance_manager: 'Compliance Manager',
|
||||||
|
board_member: 'Membro CDA',
|
||||||
|
auditor: 'Auditor',
|
||||||
|
employee: 'Dipendente'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Caricamento ──────────────────────────────────────────
|
||||||
|
async function loadUsers(page) {
|
||||||
|
const container = document.getElementById('users-container');
|
||||||
|
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
|
||||||
|
currentPage = page;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.request('GET', '/admin/users?page=' + page);
|
||||||
|
if (result.success) {
|
||||||
|
renderUsers(result.data || [], result.pagination || {});
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h4>Errore nel caricamento</h4>
|
||||||
|
<p>${escapeHtml(result.message || 'Errore sconosciuto')}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers(users, pagination) {
|
||||||
|
const container = document.getElementById('users-container');
|
||||||
|
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/></svg>
|
||||||
|
<h4>Nessun utente trovato</h4>
|
||||||
|
<p>Gli utenti registrati appariranno qui.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Ruolo</th>
|
||||||
|
<th>Organizzazioni</th>
|
||||||
|
<th>Ultimo Login</th>
|
||||||
|
<th>Attivo</th>
|
||||||
|
<th>Data Creazione</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
users.forEach(user => {
|
||||||
|
const role = user.role || 'employee';
|
||||||
|
const roleLabel = roleLabels[role] || role;
|
||||||
|
const isActive = user.is_active == 1 || user.is_active === true;
|
||||||
|
|
||||||
|
// Organizzazioni
|
||||||
|
let orgsHtml = '<span class="text-muted">-</span>';
|
||||||
|
if (user.organizations) {
|
||||||
|
const orgNames = user.organizations.split(',').filter(n => n.trim());
|
||||||
|
if (orgNames.length > 0) {
|
||||||
|
orgsHtml = '<div class="org-tags">';
|
||||||
|
orgNames.forEach(name => {
|
||||||
|
orgsHtml += `<span class="org-tag" title="${escapeHtml(name.trim())}">${escapeHtml(name.trim())}</span>`;
|
||||||
|
});
|
||||||
|
orgsHtml += '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ultimo login
|
||||||
|
const lastLogin = user.last_login_at ? formatDateTime(user.last_login_at) : '<span class="text-muted">Mai</span>';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(user.full_name || '-')}</strong></td>
|
||||||
|
<td>${escapeHtml(user.email || '-')}</td>
|
||||||
|
<td><span class="role-badge ${role}">${escapeHtml(roleLabel)}</span></td>
|
||||||
|
<td>${orgsHtml}</td>
|
||||||
|
<td>${lastLogin}</td>
|
||||||
|
<td>
|
||||||
|
<label class="active-toggle" title="${isActive ? 'Attivo' : 'Inattivo'}">
|
||||||
|
<input type="checkbox" ${isActive ? 'checked' : ''} onchange="toggleUserActive(${user.id}, this.checked)" ${role === 'super_admin' ? 'disabled' : ''}>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td>${formatDate(user.created_at)}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
|
||||||
|
// Paginazione
|
||||||
|
const total = pagination.total || users.length;
|
||||||
|
const perPage = pagination.per_page || 20;
|
||||||
|
const page = pagination.page || currentPage;
|
||||||
|
const totalPages = Math.ceil(total / perPage);
|
||||||
|
const start = ((page - 1) * perPage) + 1;
|
||||||
|
const end = Math.min(page * perPage, total);
|
||||||
|
|
||||||
|
if (totalPages > 1) {
|
||||||
|
html += `
|
||||||
|
<div class="pagination">
|
||||||
|
<div class="pagination-info">
|
||||||
|
Visualizzati ${start}-${end} di ${total} utenti
|
||||||
|
</div>
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="loadUsers(${page - 1})" ${page <= 1 ? 'disabled' : ''}>
|
||||||
|
« Precedente
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="loadUsers(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
|
||||||
|
Successivo »
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else if (total > 0) {
|
||||||
|
html += `
|
||||||
|
<div class="pagination">
|
||||||
|
<div class="pagination-info">
|
||||||
|
Totale: ${total} utent${total === 1 ? 'e' : 'i'}
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle Attivo/Inattivo ───────────────────────────────
|
||||||
|
async function toggleUserActive(userId, isActive) {
|
||||||
|
try {
|
||||||
|
const result = await api.request('PUT', '/admin/users/' + userId, {
|
||||||
|
is_active: isActive ? 1 : 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showNotification(
|
||||||
|
isActive ? 'Utente attivato con successo.' : 'Utente disattivato con successo.',
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nell\'aggiornamento.', 'error');
|
||||||
|
// Ricarica per resettare lo stato
|
||||||
|
loadUsers(currentPage);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
loadUsers(currentPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Inventario Asset - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="/nis2/css/style.css">
|
||||||
|
<style>
|
||||||
|
/* ── Stats Cards ────────────────────────────────────────── */
|
||||||
|
.asset-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.asset-stat-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.asset-stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.asset-stat-icon svg { width: 24px; height: 24px; }
|
||||||
|
.asset-stat-icon.primary { background: var(--primary-bg); color: var(--primary); }
|
||||||
|
.asset-stat-icon.success { background: var(--secondary-bg); color: var(--secondary); }
|
||||||
|
.asset-stat-icon.danger { background: var(--danger-bg); color: var(--danger); }
|
||||||
|
.asset-stat-icon.warning { background: var(--warning-bg); color: var(--gray-600); }
|
||||||
|
.asset-stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gray-800);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.asset-stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filters Bar ────────────────────────────────────────── */
|
||||||
|
.filters-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.filters-bar select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--gray-700);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.filters-bar select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Data Table ─────────────────────────────────────────── */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
.data-table thead {
|
||||||
|
background: var(--gray-50);
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-500);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--gray-700);
|
||||||
|
border-bottom: 1px solid var(--gray-100);
|
||||||
|
}
|
||||||
|
.data-table tr:last-child td { border-bottom: none; }
|
||||||
|
.data-table tbody tr { cursor: pointer; transition: background var(--transition-fast); }
|
||||||
|
.data-table tbody tr:hover td { background: var(--gray-50); }
|
||||||
|
|
||||||
|
/* ── Criticality Badges ─────────────────────────────────── */
|
||||||
|
.criticality-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.criticality-badge.critical { background: var(--danger-bg); color: var(--danger); }
|
||||||
|
.criticality-badge.high { background: #fff3e0; color: #e65100; }
|
||||||
|
.criticality-badge.medium { background: var(--warning-bg); color: #b8860b; }
|
||||||
|
.criticality-badge.low { background: var(--secondary-bg); color: var(--secondary); }
|
||||||
|
|
||||||
|
/* ── Status Badge ───────────────────────────────────────── */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.status-badge.active { background: var(--secondary-bg); color: var(--secondary); }
|
||||||
|
.status-badge.maintenance { background: var(--warning-bg); color: #b8860b; }
|
||||||
|
.status-badge.decommissioned { background: var(--gray-100); color: var(--gray-500); }
|
||||||
|
|
||||||
|
/* ── Asset Detail Panel ─────────────────────────────────── */
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.detail-field {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.detail-field-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-500);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.detail-field-value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--gray-800);
|
||||||
|
}
|
||||||
|
.detail-section-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-700);
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
.dep-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--primary-bg);
|
||||||
|
color: var(--primary);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 2px 4px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action Buttons ─────────────────────────────────────── */
|
||||||
|
.btn-sm {
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.btn-icon {
|
||||||
|
padding: 6px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--gray-400);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.btn-icon:hover { color: var(--primary); background: var(--primary-bg); }
|
||||||
|
.btn-icon svg { width: 18px; height: 18px; }
|
||||||
|
|
||||||
|
/* ── Type breakdown chips ───────────────────────────────── */
|
||||||
|
.type-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.type-chip {
|
||||||
|
background: var(--gray-100);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--gray-600);
|
||||||
|
}
|
||||||
|
.type-chip strong {
|
||||||
|
color: var(--gray-800);
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading/Empty ──────────────────────────────────────── */
|
||||||
|
.loading-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
.loading-state .spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--gray-200);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.empty-state-box {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
.empty-state-box svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.empty-state-box h4 {
|
||||||
|
color: var(--gray-600);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal form enhancements ───────────────────────────── */
|
||||||
|
.form-grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.asset-stats { grid-template-columns: 1fr 1fr; }
|
||||||
|
.detail-grid { grid-template-columns: 1fr; }
|
||||||
|
.filters-bar { flex-direction: column; }
|
||||||
|
.form-grid-2 { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar" id="sidebar"></aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="content-header">
|
||||||
|
<h2>Inventario Asset</h2>
|
||||||
|
<div class="content-header-actions">
|
||||||
|
<span class="text-muted" style="font-size:0.8rem; margin-right:8px;">Art. 21.2.i NIS2</span>
|
||||||
|
<button class="btn btn-primary" onclick="showCreateAssetModal()">+ Nuovo Asset</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-body">
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="asset-stats" id="asset-stats">
|
||||||
|
<div class="asset-stat-card">
|
||||||
|
<div class="asset-stat-icon primary">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clip-rule="evenodd"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="asset-stat-value" id="stat-total">--</div>
|
||||||
|
<div class="asset-stat-label">Totale Asset</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="asset-stat-card">
|
||||||
|
<div class="asset-stat-icon success">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="asset-stat-value" id="stat-types">--</div>
|
||||||
|
<div class="asset-stat-label">Tipologie</div>
|
||||||
|
<div class="type-chips" id="stat-type-chips"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="asset-stat-card">
|
||||||
|
<div class="asset-stat-icon danger">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="asset-stat-value" id="stat-critical">--</div>
|
||||||
|
<div class="asset-stat-label">Asset Critici</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="asset-stat-card">
|
||||||
|
<div class="asset-stat-icon warning">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="asset-stat-value" id="stat-decommissioned">--</div>
|
||||||
|
<div class="asset-stat-label">Dismessi</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="filters-bar">
|
||||||
|
<select id="filter-type" onchange="applyFilters()">
|
||||||
|
<option value="">Tutti i tipi</option>
|
||||||
|
<option value="hardware">Hardware</option>
|
||||||
|
<option value="software">Software</option>
|
||||||
|
<option value="network">Rete</option>
|
||||||
|
<option value="data">Dati</option>
|
||||||
|
<option value="service">Servizio</option>
|
||||||
|
<option value="personnel">Personale</option>
|
||||||
|
<option value="facility">Struttura</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-criticality" onchange="applyFilters()">
|
||||||
|
<option value="">Tutte le criticita'</option>
|
||||||
|
<option value="critical">Critica</option>
|
||||||
|
<option value="high">Alta</option>
|
||||||
|
<option value="medium">Media</option>
|
||||||
|
<option value="low">Bassa</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-status" onchange="applyFilters()">
|
||||||
|
<option value="">Tutti gli stati</option>
|
||||||
|
<option value="active">Attivo</option>
|
||||||
|
<option value="maintenance">Manutenzione</option>
|
||||||
|
<option value="decommissioned">Dismesso</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Asset Table -->
|
||||||
|
<div id="assets-container">
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Caricamento asset...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/nis2/js/api.js"></script>
|
||||||
|
<script src="/nis2/js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── Auth & Init ─────────────────────────────────────────
|
||||||
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
loadSidebar();
|
||||||
|
|
||||||
|
// ── Labels ──────────────────────────────────────────────
|
||||||
|
const typeLabels = {
|
||||||
|
hardware: 'Hardware',
|
||||||
|
software: 'Software',
|
||||||
|
network: 'Rete',
|
||||||
|
data: 'Dati',
|
||||||
|
service: 'Servizio',
|
||||||
|
personnel: 'Personale',
|
||||||
|
facility: 'Struttura'
|
||||||
|
};
|
||||||
|
|
||||||
|
const criticalityLabels = {
|
||||||
|
critical: 'Critica',
|
||||||
|
high: 'Alta',
|
||||||
|
medium: 'Media',
|
||||||
|
low: 'Bassa'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
active: 'Attivo',
|
||||||
|
maintenance: 'Manutenzione',
|
||||||
|
decommissioned: 'Dismesso'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────────────────
|
||||||
|
let allAssets = [];
|
||||||
|
|
||||||
|
// ── Load Assets ─────────────────────────────────────────
|
||||||
|
async function loadAssets() {
|
||||||
|
const container = document.getElementById('assets-container');
|
||||||
|
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento asset...</p></div>';
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
const type = document.getElementById('filter-type').value;
|
||||||
|
const criticality = document.getElementById('filter-criticality').value;
|
||||||
|
const status = document.getElementById('filter-status').value;
|
||||||
|
if (type) params.asset_type = type;
|
||||||
|
if (criticality) params.criticality = criticality;
|
||||||
|
if (status) params.status = status;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.listAssets(params);
|
||||||
|
if (result.success) {
|
||||||
|
allAssets = result.data || [];
|
||||||
|
updateStats(allAssets);
|
||||||
|
renderAssets(allAssets);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4><p>' + escapeHtml(result.message || '') + '</p></div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4><p>Impossibile caricare gli asset.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
loadAssets();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats(assets) {
|
||||||
|
// For stats, we count from the current filtered results
|
||||||
|
// In a full implementation, stats would come from the server unfiltered
|
||||||
|
document.getElementById('stat-total').textContent = assets.length;
|
||||||
|
|
||||||
|
// Type breakdown
|
||||||
|
const typeCount = {};
|
||||||
|
let criticalCount = 0;
|
||||||
|
let decommCount = 0;
|
||||||
|
assets.forEach(a => {
|
||||||
|
const t = a.asset_type || 'unknown';
|
||||||
|
typeCount[t] = (typeCount[t] || 0) + 1;
|
||||||
|
if (a.criticality === 'critical') criticalCount++;
|
||||||
|
if (a.status === 'decommissioned') decommCount++;
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueTypes = Object.keys(typeCount).length;
|
||||||
|
document.getElementById('stat-types').textContent = uniqueTypes;
|
||||||
|
document.getElementById('stat-critical').textContent = criticalCount;
|
||||||
|
document.getElementById('stat-decommissioned').textContent = decommCount;
|
||||||
|
|
||||||
|
// Type chips
|
||||||
|
const chipsEl = document.getElementById('stat-type-chips');
|
||||||
|
chipsEl.innerHTML = Object.entries(typeCount).map(([type, count]) =>
|
||||||
|
`<span class="type-chip"><strong>${count}</strong> ${typeLabels[type] || type}</span>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssets(assets) {
|
||||||
|
const container = document.getElementById('assets-container');
|
||||||
|
|
||||||
|
if (!assets || assets.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state-box">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clip-rule="evenodd"/></svg>
|
||||||
|
<h4>Nessun asset trovato</h4>
|
||||||
|
<p>Registra il primo asset per costruire l'inventario.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Categoria</th>
|
||||||
|
<th>Criticita'</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th>Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
assets.forEach(asset => {
|
||||||
|
const crit = asset.criticality || 'medium';
|
||||||
|
const st = asset.status || 'active';
|
||||||
|
html += `
|
||||||
|
<tr onclick="showAssetDetail(${asset.id})" title="Visualizza dettagli">
|
||||||
|
<td style="font-weight:500;">${escapeHtml(asset.name)}</td>
|
||||||
|
<td>${typeLabels[asset.asset_type] || asset.asset_type || '-'}</td>
|
||||||
|
<td>${escapeHtml(asset.category || '-')}</td>
|
||||||
|
<td><span class="criticality-badge ${crit}">${criticalityLabels[crit] || crit}</span></td>
|
||||||
|
<td>${escapeHtml(asset.owner_name || '-')}</td>
|
||||||
|
<td><span class="status-badge ${st}">${statusLabels[st] || st}</span></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-icon" onclick="event.stopPropagation(); showEditAssetModal(${asset.id})" title="Modifica">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Asset Detail View ───────────────────────────────────
|
||||||
|
async function showAssetDetail(id) {
|
||||||
|
try {
|
||||||
|
const result = await api.getAsset(id);
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
showNotification('Asset non trovato.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = result.data;
|
||||||
|
const crit = a.criticality || 'medium';
|
||||||
|
const st = a.status || 'active';
|
||||||
|
|
||||||
|
let depsHtml = '<span style="color:var(--gray-400);">Nessuna dipendenza</span>';
|
||||||
|
if (a.dependencies) {
|
||||||
|
let deps = a.dependencies;
|
||||||
|
if (typeof deps === 'string') {
|
||||||
|
try { deps = JSON.parse(deps); } catch(e) { deps = []; }
|
||||||
|
}
|
||||||
|
if (deps && deps.length > 0) {
|
||||||
|
depsHtml = deps.map(d => `<span class="dep-tag">ID: ${escapeHtml(String(d))}</span>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showModal(escapeHtml(a.name), `
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="detail-field-label">Tipo</div>
|
||||||
|
<div class="detail-field-value">${typeLabels[a.asset_type] || a.asset_type || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="detail-field-label">Categoria</div>
|
||||||
|
<div class="detail-field-value">${escapeHtml(a.category || '-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="detail-field-label">Criticita'</div>
|
||||||
|
<div class="detail-field-value"><span class="criticality-badge ${crit}">${criticalityLabels[crit] || crit}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="detail-field-label">Stato</div>
|
||||||
|
<div class="detail-field-value"><span class="status-badge ${st}">${statusLabels[st] || st}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="detail-field-label">Owner</div>
|
||||||
|
<div class="detail-field-value">${escapeHtml(a.owner_name || '-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="detail-field-label">Posizione</div>
|
||||||
|
<div class="detail-field-value">${escapeHtml(a.location || '-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="detail-field-label">Indirizzo IP</div>
|
||||||
|
<div class="detail-field-value">${escapeHtml(a.ip_address || '-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="detail-field-label">Fornitore</div>
|
||||||
|
<div class="detail-field-value">${escapeHtml(a.vendor || '-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="detail-field-label">Versione</div>
|
||||||
|
<div class="detail-field-value">${escapeHtml(a.version || '-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="detail-field-label">Numero Seriale</div>
|
||||||
|
<div class="detail-field-value">${escapeHtml(a.serial_number || '-')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="detail-field-label">Data Acquisto</div>
|
||||||
|
<div class="detail-field-value">${formatDate(a.purchase_date)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
|
<div class="detail-field-label">Scadenza Garanzia</div>
|
||||||
|
<div class="detail-field-value">${formatDate(a.warranty_expiry)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${a.description ? `
|
||||||
|
<div class="detail-section-title">Descrizione</div>
|
||||||
|
<p style="font-size:0.9rem; color:var(--gray-600);">${escapeHtml(a.description)}</p>
|
||||||
|
` : ''}
|
||||||
|
<div class="detail-section-title">Dipendenze</div>
|
||||||
|
<div>${depsHtml}</div>
|
||||||
|
`, {
|
||||||
|
size: 'lg',
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>
|
||||||
|
<button class="btn btn-primary" onclick="closeModal(); showEditAssetModal(${a.id})">Modifica</button>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore nel caricamento dell\'asset.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create Asset Modal ──────────────────────────────────
|
||||||
|
function showCreateAssetModal() {
|
||||||
|
showAssetModal('Nuovo Asset', {}, async function(data) {
|
||||||
|
try {
|
||||||
|
const result = await api.createAsset(data);
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Asset registrato con successo!', 'success');
|
||||||
|
closeModal();
|
||||||
|
loadAssets();
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nella creazione.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Asset Modal ────────────────────────────────────
|
||||||
|
async function showEditAssetModal(id) {
|
||||||
|
try {
|
||||||
|
const result = await api.getAsset(id);
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
showNotification('Asset non trovato.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showAssetModal('Modifica Asset', result.data, async function(data) {
|
||||||
|
try {
|
||||||
|
const res = await api.updateAsset(id, data);
|
||||||
|
if (res.success) {
|
||||||
|
showNotification('Asset aggiornato con successo!', 'success');
|
||||||
|
closeModal();
|
||||||
|
loadAssets();
|
||||||
|
} else {
|
||||||
|
showNotification(res.message || 'Errore nell\'aggiornamento.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore nel caricamento.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generic Asset Modal ─────────────────────────────────
|
||||||
|
function showAssetModal(title, asset, onSave) {
|
||||||
|
const a = asset || {};
|
||||||
|
|
||||||
|
const typeOptions = Object.entries(typeLabels).map(([val, label]) =>
|
||||||
|
`<option value="${val}" ${a.asset_type === val ? 'selected' : ''}>${label}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const critOptions = Object.entries(criticalityLabels).map(([val, label]) =>
|
||||||
|
`<option value="${val}" ${a.criticality === val ? 'selected' : ''}>${label}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const statusOptions = Object.entries(statusLabels).map(([val, label]) =>
|
||||||
|
`<option value="${val}" ${a.status === val ? 'selected' : ''}>${label}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Store the callback
|
||||||
|
window._assetSaveCallback = onSave;
|
||||||
|
|
||||||
|
showModal(title, `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nome *</label>
|
||||||
|
<input type="text" class="form-input" id="asset-name" value="${escapeHtml(a.name || '')}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tipo *</label>
|
||||||
|
<select class="form-select" id="asset-type">${typeOptions}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Categoria</label>
|
||||||
|
<input type="text" class="form-input" id="asset-category" value="${escapeHtml(a.category || '')}" placeholder="Es: Server, Database...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Descrizione</label>
|
||||||
|
<textarea class="form-input" id="asset-description" rows="2">${escapeHtml(a.description || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Criticita'</label>
|
||||||
|
<select class="form-select" id="asset-criticality">${critOptions}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Stato</label>
|
||||||
|
<select class="form-select" id="asset-status">${statusOptions}</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Owner (ID utente)</label>
|
||||||
|
<input type="text" class="form-input" id="asset-owner" value="${escapeHtml(a.owner_user_id || '')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Posizione</label>
|
||||||
|
<input type="text" class="form-input" id="asset-location" value="${escapeHtml(a.location || '')}" placeholder="Es: Data Center Roma">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Indirizzo IP</label>
|
||||||
|
<input type="text" class="form-input" id="asset-ip" value="${escapeHtml(a.ip_address || '')}" placeholder="Es: 192.168.1.100">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Fornitore</label>
|
||||||
|
<input type="text" class="form-input" id="asset-vendor" value="${escapeHtml(a.vendor || '')}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Versione</label>
|
||||||
|
<input type="text" class="form-input" id="asset-version" value="${escapeHtml(a.version || '')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Numero Seriale</label>
|
||||||
|
<input type="text" class="form-input" id="asset-serial" value="${escapeHtml(a.serial_number || '')}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Data Acquisto</label>
|
||||||
|
<input type="date" class="form-input" id="asset-purchase-date" value="${a.purchase_date || ''}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Scadenza Garanzia</label>
|
||||||
|
<input type="date" class="form-input" id="asset-warranty" value="${a.warranty_expiry || ''}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`, {
|
||||||
|
size: 'lg',
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveAsset()">Salva</button>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAsset() {
|
||||||
|
const name = document.getElementById('asset-name').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showNotification('Il nome e\' obbligatorio.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: name,
|
||||||
|
asset_type: document.getElementById('asset-type').value,
|
||||||
|
category: document.getElementById('asset-category').value.trim() || null,
|
||||||
|
description: document.getElementById('asset-description').value.trim() || null,
|
||||||
|
criticality: document.getElementById('asset-criticality').value,
|
||||||
|
owner_user_id: document.getElementById('asset-owner').value.trim() || null,
|
||||||
|
location: document.getElementById('asset-location').value.trim() || null,
|
||||||
|
ip_address: document.getElementById('asset-ip').value.trim() || null,
|
||||||
|
vendor: document.getElementById('asset-vendor').value.trim() || null,
|
||||||
|
version: document.getElementById('asset-version').value.trim() || null,
|
||||||
|
serial_number: document.getElementById('asset-serial').value.trim() || null,
|
||||||
|
purchase_date: document.getElementById('asset-purchase-date').value || null,
|
||||||
|
warranty_expiry: document.getElementById('asset-warranty').value || null,
|
||||||
|
status: document.getElementById('asset-status').value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window._assetSaveCallback) {
|
||||||
|
window._assetSaveCallback(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initial Load ────────────────────────────────────────
|
||||||
|
loadAssets();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,930 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Policy - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="/nis2/css/style.css">
|
||||||
|
<style>
|
||||||
|
/* ── Policy Page Styles ──────────────────────────────────── */
|
||||||
|
.filters-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.filters-bar .form-select {
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.filters-bar .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.policy-actions .btn {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid var(--gray-200);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-500);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--gray-700);
|
||||||
|
}
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--primary);
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
}
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.template-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.template-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: var(--card-shadow-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.template-card h4 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--gray-800);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.template-card .template-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.template-card .template-meta .tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.detail-header h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--gray-900);
|
||||||
|
}
|
||||||
|
.detail-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 300px;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content-box {
|
||||||
|
background: var(--gray-50);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 24px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--gray-700);
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.meta-card h4 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-800);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--gray-100);
|
||||||
|
}
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
border-bottom: 1px solid var(--gray-50);
|
||||||
|
}
|
||||||
|
.meta-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.meta-label {
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.meta-value {
|
||||||
|
color: var(--gray-800);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview {
|
||||||
|
background: var(--gray-50);
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 20px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.ai-preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.ai-preview-header svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
.loading-overlay .spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--gray-200);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link {
|
||||||
|
color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.text-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar" id="sidebar"></aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="content-header">
|
||||||
|
<h2>Policy</h2>
|
||||||
|
<div class="content-header-actions">
|
||||||
|
<button class="btn btn-outline" onclick="openAIGenerateModal()">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
|
||||||
|
Genera con AI
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick="openCreateModal()">+ Nuova Policy</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-body" id="page-content">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" onclick="switchTab('list')">Elenco Policy</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('templates')">Template</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Lista Policy -->
|
||||||
|
<div class="tab-content active" id="tab-list">
|
||||||
|
<div class="filters-bar">
|
||||||
|
<select class="form-select" id="filter-category" onchange="loadPolicies()">
|
||||||
|
<option value="">Tutte le Categorie</option>
|
||||||
|
<option value="information_security">Sicurezza Informazioni</option>
|
||||||
|
<option value="access_control">Controllo Accessi</option>
|
||||||
|
<option value="incident_response">Risposta Incidenti</option>
|
||||||
|
<option value="business_continuity">Continuita' Operativa</option>
|
||||||
|
<option value="supply_chain">Supply Chain</option>
|
||||||
|
<option value="encryption">Crittografia</option>
|
||||||
|
<option value="hr_security">Sicurezza HR</option>
|
||||||
|
<option value="asset_management">Gestione Asset</option>
|
||||||
|
<option value="network_security">Sicurezza Rete</option>
|
||||||
|
<option value="vulnerability_management">Gestione Vulnerabilita'</option>
|
||||||
|
</select>
|
||||||
|
<select class="form-select" id="filter-status" onchange="loadPolicies()">
|
||||||
|
<option value="">Tutti gli Stati</option>
|
||||||
|
<option value="draft">Bozza</option>
|
||||||
|
<option value="review">In Revisione</option>
|
||||||
|
<option value="approved">Approvata</option>
|
||||||
|
<option value="published">Pubblicata</option>
|
||||||
|
<option value="archived">Archiviata</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-container" id="policies-table">
|
||||||
|
<div class="loading-overlay">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Caricamento policy...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Template -->
|
||||||
|
<div class="tab-content" id="tab-templates">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Template Policy NIS2</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="templates-grid">
|
||||||
|
<div class="loading-overlay">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Caricamento template...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail View (hidden by default) -->
|
||||||
|
<div id="policy-detail-view" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/nis2/js/api.js"></script>
|
||||||
|
<script src="/nis2/js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── Auth & Init ─────────────────────────────────────────
|
||||||
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
loadSidebar();
|
||||||
|
|
||||||
|
// ── Labels ──────────────────────────────────────────────
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
information_security: 'Sicurezza Informazioni',
|
||||||
|
access_control: 'Controllo Accessi',
|
||||||
|
incident_response: 'Risposta Incidenti',
|
||||||
|
business_continuity: 'Continuita\' Operativa',
|
||||||
|
supply_chain: 'Supply Chain',
|
||||||
|
encryption: 'Crittografia',
|
||||||
|
hr_security: 'Sicurezza HR',
|
||||||
|
asset_management: 'Gestione Asset',
|
||||||
|
network_security: 'Sicurezza Rete',
|
||||||
|
vulnerability_management: 'Gestione Vulnerabilita\''
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
draft: 'Bozza',
|
||||||
|
review: 'In Revisione',
|
||||||
|
approved: 'Approvata',
|
||||||
|
published: 'Pubblicata',
|
||||||
|
archived: 'Archiviata'
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGE_CLASS = {
|
||||||
|
draft: 'badge-neutral',
|
||||||
|
review: 'badge-info',
|
||||||
|
approved: 'badge-success',
|
||||||
|
published: 'badge-primary',
|
||||||
|
archived: 'badge-neutral'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────────────────
|
||||||
|
let currentView = 'list'; // 'list' | 'detail'
|
||||||
|
let policiesData = [];
|
||||||
|
|
||||||
|
// ── Tab switching ───────────────────────────────────────
|
||||||
|
function switchTab(tab) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
|
if (tab === 'list') {
|
||||||
|
document.querySelectorAll('.tab-btn')[0].classList.add('active');
|
||||||
|
document.getElementById('tab-list').classList.add('active');
|
||||||
|
} else if (tab === 'templates') {
|
||||||
|
document.querySelectorAll('.tab-btn')[1].classList.add('active');
|
||||||
|
document.getElementById('tab-templates').classList.add('active');
|
||||||
|
loadTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide detail view when switching tabs
|
||||||
|
document.getElementById('policy-detail-view').style.display = 'none';
|
||||||
|
document.querySelector('.tabs').style.display = '';
|
||||||
|
document.getElementById('tab-list').style.display = '';
|
||||||
|
document.getElementById('tab-templates').style.display = '';
|
||||||
|
currentView = 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load Policies ───────────────────────────────────────
|
||||||
|
async function loadPolicies() {
|
||||||
|
const params = {};
|
||||||
|
const category = document.getElementById('filter-category').value;
|
||||||
|
const status = document.getElementById('filter-status').value;
|
||||||
|
if (category) params.category = category;
|
||||||
|
if (status) params.status = status;
|
||||||
|
|
||||||
|
const container = document.getElementById('policies-table');
|
||||||
|
container.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento policy...</span></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.listPolicies(params);
|
||||||
|
if (result.success) {
|
||||||
|
policiesData = result.data || [];
|
||||||
|
renderPoliciesTable(policiesData);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="empty-state"><h4>Errore nel caricamento</h4><p>' + escapeHtml(result.message || 'Errore sconosciuto') + '</p></div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4><p>Impossibile caricare le policy.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPoliciesTable(policies) {
|
||||||
|
const container = document.getElementById('policies-table');
|
||||||
|
|
||||||
|
if (!policies || policies.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state" style="padding:40px 20px;">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" style="width:48px;height:48px;color:var(--gray-300);">
|
||||||
|
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<h4>Nessuna policy trovata</h4>
|
||||||
|
<p>Crea la tua prima policy o utilizza un template per iniziare.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Titolo</th>
|
||||||
|
<th>Categoria</th>
|
||||||
|
<th>Art. NIS2</th>
|
||||||
|
<th>Versione</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th>Prossima Revisione</th>
|
||||||
|
<th>Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
policies.forEach(p => {
|
||||||
|
const catLabel = CATEGORY_LABELS[p.category] || p.category || '-';
|
||||||
|
const statusLabel = STATUS_LABELS[p.status] || p.status || 'Bozza';
|
||||||
|
const badgeClass = STATUS_BADGE_CLASS[p.status] || 'badge-neutral';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="text-link" onclick="viewPolicy(${p.id})">${escapeHtml(p.title)}</span>
|
||||||
|
</td>
|
||||||
|
<td>${escapeHtml(catLabel)}</td>
|
||||||
|
<td>${escapeHtml(p.nis2_article || '-')}</td>
|
||||||
|
<td>${escapeHtml(p.version || '1.0')}</td>
|
||||||
|
<td><span class="badge ${badgeClass}">${escapeHtml(statusLabel)}</span></td>
|
||||||
|
<td>${formatDate(p.next_review_date)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="policy-actions">
|
||||||
|
<button class="btn btn-sm btn-ghost" onclick="viewPolicy(${p.id})" title="Visualizza">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-ghost" onclick="openEditModal(${p.id})" title="Modifica">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
||||||
|
</button>
|
||||||
|
${p.status === 'draft' || p.status === 'review' ? `
|
||||||
|
<button class="btn btn-sm btn-success" onclick="approvePolicy(${p.id})" title="Approva">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||||
|
</button>` : ''}
|
||||||
|
<button class="btn btn-sm btn-ghost" onclick="confirmDeletePolicy(${p.id}, '${escapeHtml(p.title).replace(/'/g, "\\'")}')" title="Elimina" style="color:var(--danger);">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View Policy Detail ──────────────────────────────────
|
||||||
|
async function viewPolicy(id) {
|
||||||
|
// Hide tabs and list
|
||||||
|
document.querySelector('.tabs').style.display = 'none';
|
||||||
|
document.getElementById('tab-list').classList.remove('active');
|
||||||
|
document.getElementById('tab-templates').classList.remove('active');
|
||||||
|
|
||||||
|
const detailView = document.getElementById('policy-detail-view');
|
||||||
|
detailView.style.display = 'block';
|
||||||
|
detailView.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento dettagli...</span></div>';
|
||||||
|
currentView = 'detail';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.getPolicy(id);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
renderPolicyDetail(result.data);
|
||||||
|
} else {
|
||||||
|
detailView.innerHTML = '<div class="empty-state"><h4>Policy non trovata</h4></div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
detailView.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPolicyDetail(policy) {
|
||||||
|
const detailView = document.getElementById('policy-detail-view');
|
||||||
|
const catLabel = CATEGORY_LABELS[policy.category] || policy.category || '-';
|
||||||
|
const statusLabel = STATUS_LABELS[policy.status] || policy.status || 'Bozza';
|
||||||
|
const badgeClass = STATUS_BADGE_CLASS[policy.status] || 'badge-neutral';
|
||||||
|
|
||||||
|
detailView.innerHTML = `
|
||||||
|
<div class="detail-header">
|
||||||
|
<div>
|
||||||
|
<div style="margin-bottom:8px;">
|
||||||
|
<span class="text-link" onclick="backToList()" style="font-size:0.82rem;">
|
||||||
|
← Torna all'elenco
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3>${escapeHtml(policy.title)}</h3>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<span class="badge ${badgeClass}">${escapeHtml(statusLabel)}</span>
|
||||||
|
${policy.ai_generated ? '<span class="badge badge-info" style="margin-left:6px;">AI Generata</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-header-actions">
|
||||||
|
${policy.status === 'draft' || policy.status === 'review' ? `
|
||||||
|
<button class="btn btn-success" onclick="approvePolicy(${policy.id})">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||||
|
Approva
|
||||||
|
</button>` : ''}
|
||||||
|
<button class="btn btn-secondary" onclick="openEditModal(${policy.id})">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
||||||
|
Modifica
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Contenuto Policy</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="policy-content-box">${escapeHtml(policy.content || 'Nessun contenuto disponibile.')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="meta-card">
|
||||||
|
<h4>Informazioni</h4>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Categoria</span>
|
||||||
|
<span class="meta-value">${escapeHtml(catLabel)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Art. NIS2</span>
|
||||||
|
<span class="meta-value">${escapeHtml(policy.nis2_article || '-')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Versione</span>
|
||||||
|
<span class="meta-value">${escapeHtml(policy.version || '1.0')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Stato</span>
|
||||||
|
<span class="meta-value"><span class="badge ${badgeClass}">${escapeHtml(statusLabel)}</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Creata il</span>
|
||||||
|
<span class="meta-value">${formatDate(policy.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Aggiornata il</span>
|
||||||
|
<span class="meta-value">${formatDate(policy.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Prossima Revisione</span>
|
||||||
|
<span class="meta-value">${formatDate(policy.next_review_date)}</span>
|
||||||
|
</div>
|
||||||
|
${policy.approved_by_name ? `
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Approvata da</span>
|
||||||
|
<span class="meta-value">${escapeHtml(policy.approved_by_name)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Approvata il</span>
|
||||||
|
<span class="meta-value">${formatDate(policy.approved_at)}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToList() {
|
||||||
|
document.getElementById('policy-detail-view').style.display = 'none';
|
||||||
|
document.querySelector('.tabs').style.display = '';
|
||||||
|
switchTab('list');
|
||||||
|
currentView = 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create / Edit Modal ─────────────────────────────────
|
||||||
|
function openCreateModal() {
|
||||||
|
showPolicyFormModal(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditModal(id) {
|
||||||
|
try {
|
||||||
|
const result = await api.getPolicy(id);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
showPolicyFormModal(result.data);
|
||||||
|
} else {
|
||||||
|
showNotification('Impossibile caricare la policy.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPolicyFormModal(policy) {
|
||||||
|
const isEdit = !!policy;
|
||||||
|
const title = isEdit ? 'Modifica Policy' : 'Nuova Policy';
|
||||||
|
|
||||||
|
const categoryOptions = Object.entries(CATEGORY_LABELS).map(([val, label]) =>
|
||||||
|
`<option value="${val}" ${policy && policy.category === val ? 'selected' : ''}>${label}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<form id="policy-form" onsubmit="return false;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Titolo <span class="required">*</span></label>
|
||||||
|
<input type="text" class="form-input" id="form-title" value="${isEdit ? escapeHtml(policy.title) : ''}" required placeholder="Inserisci il titolo della policy">
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Categoria <span class="required">*</span></label>
|
||||||
|
<select class="form-select" id="form-category" required>
|
||||||
|
<option value="">Seleziona...</option>
|
||||||
|
${categoryOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Articolo NIS2</label>
|
||||||
|
<input type="text" class="form-input" id="form-nis2-article" value="${isEdit && policy.nis2_article ? escapeHtml(policy.nis2_article) : ''}" placeholder="es. 21.2.a">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Versione</label>
|
||||||
|
<input type="text" class="form-input" id="form-version" value="${isEdit && policy.version ? escapeHtml(policy.version) : '1.0'}" placeholder="1.0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Prossima Revisione</label>
|
||||||
|
<input type="date" class="form-input" id="form-review-date" value="${isEdit && policy.next_review_date ? policy.next_review_date.substring(0, 10) : ''}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Contenuto</label>
|
||||||
|
<textarea class="form-textarea" id="form-content" rows="12" placeholder="Inserisci il contenuto della policy... Puoi strutturare il documento con sezioni: 1. Scopo 2. Ambito 3. Responsabilita' 4. Procedure 5. Revisione">${isEdit && policy.content ? escapeHtml(policy.content) : ''}</textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
showModal(title, content, {
|
||||||
|
size: 'lg',
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||||||
|
<button class="btn btn-primary" onclick="savePolicy(${isEdit ? policy.id : 'null'})">${isEdit ? 'Salva Modifiche' : 'Crea Policy'}</button>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePolicy(id) {
|
||||||
|
const title = document.getElementById('form-title').value.trim();
|
||||||
|
const category = document.getElementById('form-category').value;
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
showNotification('Il titolo e\' obbligatorio.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!category) {
|
||||||
|
showNotification('La categoria e\' obbligatoria.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title: title,
|
||||||
|
category: category,
|
||||||
|
nis2_article: document.getElementById('form-nis2-article').value.trim(),
|
||||||
|
version: document.getElementById('form-version').value.trim() || '1.0',
|
||||||
|
content: document.getElementById('form-content').value,
|
||||||
|
next_review_date: document.getElementById('form-review-date').value || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if (id) {
|
||||||
|
result = await api.updatePolicy(id, data);
|
||||||
|
} else {
|
||||||
|
result = await api.createPolicy(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
closeModal();
|
||||||
|
showNotification(id ? 'Policy aggiornata con successo.' : 'Policy creata con successo.', 'success');
|
||||||
|
loadPolicies();
|
||||||
|
if (currentView === 'detail' && id) {
|
||||||
|
viewPolicy(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nel salvataggio.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Approve Policy ──────────────────────────────────────
|
||||||
|
async function approvePolicy(id) {
|
||||||
|
showModal('Conferma Approvazione', `
|
||||||
|
<p>Sei sicuro di voler approvare questa policy?</p>
|
||||||
|
<p style="margin-top:8px;font-size:0.85rem;color:var(--gray-500);">Lo stato verra' modificato in "Approvata" e non sara' piu' possibile annullare l'operazione.</p>
|
||||||
|
`, {
|
||||||
|
size: 'sm',
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||||||
|
<button class="btn btn-success" onclick="doApprovePolicy(${id})">Approva</button>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doApprovePolicy(id) {
|
||||||
|
closeModal();
|
||||||
|
try {
|
||||||
|
const result = await api.approvePolicy(id);
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Policy approvata con successo.', 'success');
|
||||||
|
loadPolicies();
|
||||||
|
if (currentView === 'detail') {
|
||||||
|
viewPolicy(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nell\'approvazione.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete Policy ───────────────────────────────────────
|
||||||
|
function confirmDeletePolicy(id, title) {
|
||||||
|
showModal('Conferma Eliminazione', `
|
||||||
|
<p>Sei sicuro di voler eliminare la policy <strong>${title}</strong>?</p>
|
||||||
|
<p style="margin-top:8px;font-size:0.85rem;color:var(--danger);">Questa azione e' irreversibile.</p>
|
||||||
|
`, {
|
||||||
|
size: 'sm',
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||||||
|
<button class="btn btn-danger" onclick="doDeletePolicy(${id})">Elimina</button>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDeletePolicy(id) {
|
||||||
|
closeModal();
|
||||||
|
try {
|
||||||
|
const result = await api.del(`/policies/${id}`);
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Policy eliminata.', 'success');
|
||||||
|
loadPolicies();
|
||||||
|
if (currentView === 'detail') {
|
||||||
|
backToList();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nell\'eliminazione.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI Generate ─────────────────────────────────────────
|
||||||
|
function openAIGenerateModal() {
|
||||||
|
const categoryOptions = Object.entries(CATEGORY_LABELS).map(([val, label]) =>
|
||||||
|
`<option value="${val}">${label}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
showModal('Genera Policy con AI', `
|
||||||
|
<p style="margin-bottom:16px;color:var(--gray-600);font-size:0.9rem;">
|
||||||
|
Seleziona la categoria per generare automaticamente una bozza di policy tramite intelligenza artificiale.
|
||||||
|
Il contenuto generato potra' essere rivisto e modificato prima della pubblicazione.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Categoria Policy <span class="required">*</span></label>
|
||||||
|
<select class="form-select" id="ai-category">
|
||||||
|
${categoryOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="ai-preview-container" style="display:none;"></div>
|
||||||
|
`, {
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||||||
|
<button class="btn btn-primary" id="ai-generate-btn" onclick="doAIGenerate()">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
|
||||||
|
Genera
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let aiGeneratedData = null;
|
||||||
|
|
||||||
|
async function doAIGenerate() {
|
||||||
|
const category = document.getElementById('ai-category').value;
|
||||||
|
const btn = document.getElementById('ai-generate-btn');
|
||||||
|
const previewContainer = document.getElementById('ai-preview-container');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0 auto;"></div> Generazione...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.aiGeneratePolicy(category);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
aiGeneratedData = result.data;
|
||||||
|
previewContainer.style.display = 'block';
|
||||||
|
previewContainer.innerHTML = `
|
||||||
|
<div class="ai-preview-header">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
|
||||||
|
Policy Generata: ${escapeHtml(aiGeneratedData.title || '')}
|
||||||
|
</div>
|
||||||
|
<div class="ai-preview">${escapeHtml(aiGeneratedData.content || '')}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Change footer buttons
|
||||||
|
const footer = btn.closest('.modal-footer');
|
||||||
|
footer.innerHTML = `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveAIGeneratedPolicy()">Salva come Bozza</button>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nella generazione AI.', 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Genera';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Genera';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAIGeneratedPolicy() {
|
||||||
|
if (!aiGeneratedData) return;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title: aiGeneratedData.title || 'Policy Generata AI',
|
||||||
|
category: aiGeneratedData.category || document.getElementById('ai-category')?.value || 'information_security',
|
||||||
|
nis2_article: aiGeneratedData.nis2_article || '',
|
||||||
|
content: aiGeneratedData.content || '',
|
||||||
|
version: '1.0',
|
||||||
|
ai_generated: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.createPolicy(data);
|
||||||
|
if (result.success) {
|
||||||
|
closeModal();
|
||||||
|
showNotification('Policy AI salvata come bozza.', 'success');
|
||||||
|
aiGeneratedData = null;
|
||||||
|
loadPolicies();
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nel salvataggio.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Templates ───────────────────────────────────────────
|
||||||
|
async function loadTemplates() {
|
||||||
|
const container = document.getElementById('templates-grid');
|
||||||
|
container.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento template...</span></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.getPolicyTemplates();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
renderTemplates(result.data);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="empty-state"><h4>Nessun template disponibile</h4></div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplates(templates) {
|
||||||
|
const container = document.getElementById('templates-grid');
|
||||||
|
|
||||||
|
if (!templates || templates.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><h4>Nessun template disponibile</h4></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="template-grid">';
|
||||||
|
templates.forEach(t => {
|
||||||
|
const catLabel = CATEGORY_LABELS[t.category] || t.category;
|
||||||
|
html += `
|
||||||
|
<div class="template-card" onclick="createFromTemplate('${escapeHtml(t.category)}', '${escapeHtml(t.title).replace(/'/g, "\\'")}', '${escapeHtml(t.nis2_article || '').replace(/'/g, "\\'")}')">
|
||||||
|
<h4>${escapeHtml(t.title)}</h4>
|
||||||
|
<div class="template-meta">
|
||||||
|
<span class="tag">${escapeHtml(catLabel)}</span>
|
||||||
|
${t.nis2_article ? `<span style="color:var(--gray-400);">Art. ${escapeHtml(t.nis2_article)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFromTemplate(category, title, nis2Article) {
|
||||||
|
showPolicyFormModal({
|
||||||
|
title: title,
|
||||||
|
category: category,
|
||||||
|
nis2_article: nis2Article,
|
||||||
|
version: '1.0',
|
||||||
|
content: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ────────────────────────────────────────────────
|
||||||
|
loadPolicies();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,902 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Audit e Report - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="/nis2/css/style.css">
|
||||||
|
<style>
|
||||||
|
/* ── Tab Navigation ─────────────────────────────────────── */
|
||||||
|
.tab-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid var(--gray-200);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.tab-nav button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.tab-nav button:hover {
|
||||||
|
color: var(--gray-700);
|
||||||
|
background: var(--gray-50);
|
||||||
|
}
|
||||||
|
.tab-nav button.active {
|
||||||
|
color: var(--primary);
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
}
|
||||||
|
.tab-panel { display: none; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* ── Data Table ─────────────────────────────────────────── */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
.data-table thead {
|
||||||
|
background: var(--gray-50);
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-500);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--gray-700);
|
||||||
|
border-bottom: 1px solid var(--gray-100);
|
||||||
|
}
|
||||||
|
.data-table tr:last-child td { border-bottom: none; }
|
||||||
|
.data-table tbody tr:hover td { background: var(--gray-50); }
|
||||||
|
|
||||||
|
/* ── Status Badges ──────────────────────────────────────── */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.status-badge.not_started { background: var(--gray-100); color: var(--gray-600); }
|
||||||
|
.status-badge.in_progress { background: var(--info-bg); color: var(--info); }
|
||||||
|
.status-badge.implemented { background: var(--secondary-bg); color: var(--secondary); }
|
||||||
|
.status-badge.verified { background: var(--primary-bg); color: var(--primary); }
|
||||||
|
|
||||||
|
/* ── Progress Bar ───────────────────────────────────────── */
|
||||||
|
.progress-bar-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--gray-200);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.progress-pct {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Report Section ─────────────────────────────────────── */
|
||||||
|
.report-container {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.report-header {
|
||||||
|
background: var(--gray-800);
|
||||||
|
color: white;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.report-header h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.report-header p {
|
||||||
|
color: var(--gray-300);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.report-body {
|
||||||
|
padding: 24px 32px;
|
||||||
|
}
|
||||||
|
.report-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.report-section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-800);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid var(--primary-bg);
|
||||||
|
}
|
||||||
|
.report-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.report-summary-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--gray-50);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
.report-summary-value {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gray-800);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.report-summary-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.report-compliance-bar {
|
||||||
|
height: 20px;
|
||||||
|
background: var(--gray-200);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.report-compliance-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ISO Mapping ────────────────────────────────────────── */
|
||||||
|
.iso-mapping-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--gray-100);
|
||||||
|
}
|
||||||
|
.iso-mapping-row:last-child { border-bottom: none; }
|
||||||
|
.iso-nis2-badge {
|
||||||
|
background: var(--primary-bg);
|
||||||
|
color: var(--primary);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 90px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.iso-arrow {
|
||||||
|
color: var(--gray-300);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.iso-arrow svg { width: 20px; height: 20px; }
|
||||||
|
.iso-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
.iso-control-badge {
|
||||||
|
background: var(--secondary-bg);
|
||||||
|
color: var(--secondary);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.iso-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pagination ─────────────────────────────────────────── */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--gray-600);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: var(--primary-bg);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.pagination .page-info {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Audit Log Action ───────────────────────────────────── */
|
||||||
|
.log-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.log-entity {
|
||||||
|
background: var(--gray-100);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--gray-600);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.log-details {
|
||||||
|
max-width: 300px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--gray-400);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Controls Row Clickable ─────────────────────────────── */
|
||||||
|
.data-table tbody tr.clickable-row { cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Loading/Empty ──────────────────────────────────────── */
|
||||||
|
.loading-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
.loading-state .spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--gray-200);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.empty-state-box {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
.empty-state-box svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.empty-state-box h4 {
|
||||||
|
color: var(--gray-600);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Print ──────────────────────────────────────────────── */
|
||||||
|
@media print {
|
||||||
|
.sidebar, .content-header, .tab-nav, .btn { display: none !important; }
|
||||||
|
.main-content { margin: 0 !important; padding: 0 !important; }
|
||||||
|
.report-container { box-shadow: none; border: 1px solid #ddd; }
|
||||||
|
body { background: white; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tab-nav button { padding: 10px 12px; font-size: 0.8rem; }
|
||||||
|
.iso-mapping-row { flex-direction: column; align-items: flex-start; }
|
||||||
|
.iso-title { min-width: auto; }
|
||||||
|
.report-summary-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar" id="sidebar"></aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="content-header">
|
||||||
|
<h2>Audit e Report</h2>
|
||||||
|
<div class="content-header-actions">
|
||||||
|
<button class="btn btn-primary" onclick="generateReport()">Genera Report</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-body">
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="tab-nav">
|
||||||
|
<button class="active" onclick="switchTab('report', this)">Report Compliance</button>
|
||||||
|
<button onclick="switchTab('controls', this)">Controlli</button>
|
||||||
|
<button onclick="switchTab('audit', this)">Audit Log</button>
|
||||||
|
<button onclick="switchTab('iso', this)">ISO 27001</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Report Compliance -->
|
||||||
|
<div class="tab-panel active" id="tab-report">
|
||||||
|
<div id="report-container">
|
||||||
|
<div class="empty-state-box">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>
|
||||||
|
<h4>Nessun report generato</h4>
|
||||||
|
<p>Clicca "Genera Report" per creare un report di compliance aggiornato.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Controlli -->
|
||||||
|
<div class="tab-panel" id="tab-controls">
|
||||||
|
<div id="controls-container">
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Caricamento controlli...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Audit Log -->
|
||||||
|
<div class="tab-panel" id="tab-audit">
|
||||||
|
<div id="audit-container">
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Caricamento log...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: ISO 27001 -->
|
||||||
|
<div class="tab-panel" id="tab-iso">
|
||||||
|
<div id="iso-container">
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Caricamento mapping...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/nis2/js/api.js"></script>
|
||||||
|
<script src="/nis2/js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── Auth & Init ─────────────────────────────────────────
|
||||||
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
loadSidebar();
|
||||||
|
|
||||||
|
// ── Labels ──────────────────────────────────────────────
|
||||||
|
const controlStatusLabels = {
|
||||||
|
not_started: 'Non Iniziato',
|
||||||
|
in_progress: 'In Corso',
|
||||||
|
implemented: 'Implementato',
|
||||||
|
verified: 'Verificato'
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionLabels = {
|
||||||
|
login: 'Accesso effettuato',
|
||||||
|
logout: 'Disconnessione',
|
||||||
|
user_created: 'Utente creato',
|
||||||
|
org_created: 'Organizzazione creata',
|
||||||
|
assessment_created: 'Assessment creato',
|
||||||
|
assessment_completed: 'Assessment completato',
|
||||||
|
risk_created: 'Rischio creato',
|
||||||
|
risk_updated: 'Rischio aggiornato',
|
||||||
|
risk_deleted: 'Rischio eliminato',
|
||||||
|
incident_created: 'Incidente registrato',
|
||||||
|
incident_updated: 'Incidente aggiornato',
|
||||||
|
early_warning_sent: 'Pre-allarme inviato',
|
||||||
|
notification_sent: 'Notifica inviata',
|
||||||
|
policy_created: 'Policy creata',
|
||||||
|
policy_updated: 'Policy aggiornata',
|
||||||
|
policy_approved: 'Policy approvata',
|
||||||
|
policy_ai_generated: 'Policy generata con AI',
|
||||||
|
supplier_created: 'Fornitore creato',
|
||||||
|
supplier_assessed: 'Fornitore valutato',
|
||||||
|
course_created: 'Corso creato',
|
||||||
|
training_assigned: 'Formazione assegnata',
|
||||||
|
asset_created: 'Asset registrato',
|
||||||
|
asset_updated: 'Asset aggiornato',
|
||||||
|
asset_deleted: 'Asset eliminato',
|
||||||
|
control_updated: 'Controllo aggiornato',
|
||||||
|
evidence_uploaded: 'Evidenza caricata'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────────────────
|
||||||
|
let auditPage = 1;
|
||||||
|
let auditTotalPages = 1;
|
||||||
|
|
||||||
|
// ── Tab Switch ──────────────────────────────────────────
|
||||||
|
function switchTab(tabId, btn) {
|
||||||
|
document.querySelectorAll('.tab-nav button').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById('tab-' + tabId).classList.add('active');
|
||||||
|
|
||||||
|
if (tabId === 'controls') loadControls();
|
||||||
|
if (tabId === 'audit') { auditPage = 1; loadAuditLogs(); }
|
||||||
|
if (tabId === 'iso') loadIsoMapping();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// TAB 1: Compliance Report
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
async function generateReport() {
|
||||||
|
const container = document.getElementById('report-container');
|
||||||
|
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Generazione report in corso...</p></div>';
|
||||||
|
|
||||||
|
// Switch to report tab
|
||||||
|
document.querySelectorAll('.tab-nav button').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelector('.tab-nav button').classList.add('active');
|
||||||
|
document.getElementById('tab-report').classList.add('active');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.generateComplianceReport();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
renderReport(result.data);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore nella generazione</h4><p>' + escapeHtml(result.message || '') + '</p></div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReport(data) {
|
||||||
|
const container = document.getElementById('report-container');
|
||||||
|
const org = data.organization || {};
|
||||||
|
const summary = data.compliance_summary || {};
|
||||||
|
const controls = data.controls || [];
|
||||||
|
const pct = summary.compliance_percentage || 0;
|
||||||
|
const pctColor = pct >= 80 ? 'var(--secondary)' : pct >= 50 ? 'var(--warning)' : 'var(--danger)';
|
||||||
|
|
||||||
|
let controlsTableHtml = '';
|
||||||
|
controls.forEach(c => {
|
||||||
|
const st = c.status || 'not_started';
|
||||||
|
const implPct = c.implementation_percentage || 0;
|
||||||
|
const implColor = implPct >= 80 ? 'var(--secondary)' : implPct >= 50 ? 'var(--warning)' : 'var(--danger)';
|
||||||
|
controlsTableHtml += `
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:var(--font-mono); font-size:0.8rem;">${escapeHtml(c.control_code || '-')}</td>
|
||||||
|
<td>${escapeHtml(c.title || '-')}</td>
|
||||||
|
<td><span class="status-badge ${st}">${controlStatusLabels[st] || st}</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar"><div class="progress-bar-fill" style="width:${implPct}%; background:${implColor};"></div></div>
|
||||||
|
<span class="progress-pct">${implPct}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="report-container">
|
||||||
|
<div class="report-header">
|
||||||
|
<h3>Report di Compliance NIS2</h3>
|
||||||
|
<p>${escapeHtml(org.name || 'Organizzazione')} - ${formatDateTime(data.report_date)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="report-body">
|
||||||
|
<!-- Organization Info -->
|
||||||
|
<div class="report-section">
|
||||||
|
<div class="report-section-title">Informazioni Organizzazione</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; font-size:0.9rem;">
|
||||||
|
<div><strong>Nome:</strong> ${escapeHtml(org.name || '-')}</div>
|
||||||
|
<div><strong>Settore:</strong> ${escapeHtml(org.sector || '-')}</div>
|
||||||
|
<div><strong>Tipo Entita':</strong> ${escapeHtml(org.entity_type || '-')}</div>
|
||||||
|
<div><strong>Paese:</strong> ${escapeHtml(org.country || '-')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compliance Summary -->
|
||||||
|
<div class="report-section">
|
||||||
|
<div class="report-section-title">Riepilogo Compliance</div>
|
||||||
|
<div class="report-compliance-bar">
|
||||||
|
<div class="report-compliance-fill" style="width:${pct}%; background:${pctColor};">${pct}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="report-summary-grid">
|
||||||
|
<div class="report-summary-item">
|
||||||
|
<div class="report-summary-value">${summary.total_controls || 0}</div>
|
||||||
|
<div class="report-summary-label">Controlli Totali</div>
|
||||||
|
</div>
|
||||||
|
<div class="report-summary-item">
|
||||||
|
<div class="report-summary-value">${summary.implemented_controls || 0}</div>
|
||||||
|
<div class="report-summary-label">Implementati</div>
|
||||||
|
</div>
|
||||||
|
<div class="report-summary-item">
|
||||||
|
<div class="report-summary-value" style="color:var(--danger);">${data.risk_count || 0}</div>
|
||||||
|
<div class="report-summary-label">Rischi Aperti</div>
|
||||||
|
</div>
|
||||||
|
<div class="report-summary-item">
|
||||||
|
<div class="report-summary-value" style="color:var(--warning);">${data.incident_count || 0}</div>
|
||||||
|
<div class="report-summary-label">Incidenti</div>
|
||||||
|
</div>
|
||||||
|
<div class="report-summary-item">
|
||||||
|
<div class="report-summary-value" style="color:var(--secondary);">${data.policy_count || 0}</div>
|
||||||
|
<div class="report-summary-label">Policy Approvate</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls Status -->
|
||||||
|
${controls.length > 0 ? `
|
||||||
|
<div class="report-section">
|
||||||
|
<div class="report-section-title">Stato Controlli</div>
|
||||||
|
<table class="data-table" style="box-shadow:none; border:1px solid var(--gray-200);">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Codice</th>
|
||||||
|
<th>Titolo</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th>Implementazione</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${controlsTableHtml}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Print Button -->
|
||||||
|
<div style="text-align:center; margin-top:24px;">
|
||||||
|
<button class="btn btn-secondary" onclick="window.print()" style="gap:6px;">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M5 4v3H4a2 2 0 00-2 2v3a2 2 0 002 2h1v2a2 2 0 002 2h6a2 2 0 002-2v-2h1a2 2 0 002-2V9a2 2 0 00-2-2h-1V4a2 2 0 00-2-2H7a2 2 0 00-2 2zm8 0H7v3h6V4zm0 8H7v4h6v-4z" clip-rule="evenodd"/></svg>
|
||||||
|
Stampa Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// TAB 2: Controls
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
async function loadControls() {
|
||||||
|
const container = document.getElementById('controls-container');
|
||||||
|
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento controlli...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.listControls();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
renderControls(result.data);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4></div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderControls(controls) {
|
||||||
|
const container = document.getElementById('controls-container');
|
||||||
|
|
||||||
|
if (!controls || controls.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state-box">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||||
|
<h4>Nessun controllo configurato</h4>
|
||||||
|
<p>I controlli di compliance appariranno qui dopo il primo assessment.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Codice</th>
|
||||||
|
<th>Framework</th>
|
||||||
|
<th>Titolo</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th>% Implementazione</th>
|
||||||
|
<th>Responsabile</th>
|
||||||
|
<th>Prossima Verifica</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
controls.forEach(c => {
|
||||||
|
const st = c.status || 'not_started';
|
||||||
|
const implPct = c.implementation_percentage || 0;
|
||||||
|
const implColor = implPct >= 80 ? 'var(--secondary)' : implPct >= 50 ? 'var(--warning)' : 'var(--danger)';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr class="clickable-row" onclick="showEditControlModal(${c.id}, this)" title="Clicca per modificare">
|
||||||
|
<td style="font-family:var(--font-mono); font-size:0.82rem; font-weight:600;">${escapeHtml(c.control_code || '-')}</td>
|
||||||
|
<td>${escapeHtml(c.framework || 'NIS2')}</td>
|
||||||
|
<td style="font-weight:500;">${escapeHtml(c.title || '-')}</td>
|
||||||
|
<td><span class="status-badge ${st}">${controlStatusLabels[st] || st}</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar"><div class="progress-bar-fill" style="width:${implPct}%; background:${implColor};"></div></div>
|
||||||
|
<span class="progress-pct">${implPct}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>${escapeHtml(c.responsible_name || '-')}</td>
|
||||||
|
<td>${formatDate(c.next_review_date)}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEditControlModal(id) {
|
||||||
|
// First, find the control from the table data
|
||||||
|
api.listControls().then(result => {
|
||||||
|
if (!result.success || !result.data) return;
|
||||||
|
const control = result.data.find(c => c.id === id);
|
||||||
|
if (!control) return;
|
||||||
|
|
||||||
|
const st = control.status || 'not_started';
|
||||||
|
const implPct = control.implementation_percentage || 0;
|
||||||
|
|
||||||
|
const statusOptions = Object.entries(controlStatusLabels).map(([val, label]) =>
|
||||||
|
`<option value="${val}" ${st === val ? 'selected' : ''}>${label}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
showModal('Modifica Controllo: ' + escapeHtml(control.control_code || ''), `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Titolo</label>
|
||||||
|
<p style="font-size:0.9rem; color:var(--gray-700); padding:8px 0;">${escapeHtml(control.title || '-')}</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Stato</label>
|
||||||
|
<select class="form-select" id="ctrl-status">${statusOptions}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">% Implementazione</label>
|
||||||
|
<input type="range" id="ctrl-impl-pct" min="0" max="100" value="${implPct}" oninput="document.getElementById('ctrl-impl-pct-val').textContent = this.value + '%'" style="width:100%; margin-top:8px;">
|
||||||
|
<span id="ctrl-impl-pct-val" style="font-size:0.85rem; font-weight:600; color:var(--gray-700);">${implPct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Descrizione Evidenza</label>
|
||||||
|
<textarea class="form-input" id="ctrl-evidence" rows="3" placeholder="Descrivi le evidenze di implementazione...">${escapeHtml(control.evidence_description || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Responsabile (ID utente)</label>
|
||||||
|
<input type="text" class="form-input" id="ctrl-responsible" value="${escapeHtml(control.responsible_user_id || '')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Prossima Verifica</label>
|
||||||
|
<input type="date" class="form-input" id="ctrl-next-review" value="${control.next_review_date || ''}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`, {
|
||||||
|
size: 'lg',
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveControl(${id})">Salva</button>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveControl(id) {
|
||||||
|
const data = {
|
||||||
|
status: document.getElementById('ctrl-status').value,
|
||||||
|
implementation_percentage: parseInt(document.getElementById('ctrl-impl-pct').value),
|
||||||
|
evidence_description: document.getElementById('ctrl-evidence').value.trim() || null,
|
||||||
|
responsible_user_id: document.getElementById('ctrl-responsible').value.trim() || null,
|
||||||
|
next_review_date: document.getElementById('ctrl-next-review').value || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.updateControl(id, data);
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Controllo aggiornato con successo!', 'success');
|
||||||
|
loadControls();
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nell\'aggiornamento.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// TAB 3: Audit Logs
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
async function loadAuditLogs() {
|
||||||
|
const container = document.getElementById('audit-container');
|
||||||
|
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento log...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.getAuditLogs({ page: auditPage, per_page: 25 });
|
||||||
|
if (result.success) {
|
||||||
|
const logs = result.data || [];
|
||||||
|
const pagination = result.pagination || {};
|
||||||
|
auditTotalPages = pagination.total_pages || 1;
|
||||||
|
renderAuditLogs(logs, pagination);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4><p>' + escapeHtml(result.message || '') + '</p></div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuditLogs(logs, pagination) {
|
||||||
|
const container = document.getElementById('audit-container');
|
||||||
|
|
||||||
|
if (!logs || logs.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state-box">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
|
||||||
|
<h4>Nessun log disponibile</h4>
|
||||||
|
<p>Le attivita' di audit appariranno qui.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Data/Ora</th>
|
||||||
|
<th>Utente</th>
|
||||||
|
<th>Azione</th>
|
||||||
|
<th>Entita'</th>
|
||||||
|
<th>Dettagli</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
logs.forEach(log => {
|
||||||
|
const actionText = actionLabels[log.action] || log.action || '-';
|
||||||
|
let detailsText = '-';
|
||||||
|
if (log.details) {
|
||||||
|
try {
|
||||||
|
const d = typeof log.details === 'string' ? JSON.parse(log.details) : log.details;
|
||||||
|
detailsText = Object.entries(d).map(([k, v]) => `${k}: ${v}`).join(', ');
|
||||||
|
} catch (e) {
|
||||||
|
detailsText = String(log.details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap;">${formatDateTime(log.created_at)}</td>
|
||||||
|
<td>${escapeHtml(log.full_name || '-')}</td>
|
||||||
|
<td><span class="log-action">${escapeHtml(actionText)}</span></td>
|
||||||
|
<td>
|
||||||
|
${log.entity_type ? `<span class="log-entity">${escapeHtml(log.entity_type)}</span>` : '-'}
|
||||||
|
${log.entity_id ? `<span style="font-size:0.8rem; color:var(--gray-400);"> #${log.entity_id}</span>` : ''}
|
||||||
|
</td>
|
||||||
|
<td><span class="log-details" title="${escapeHtml(detailsText)}">${escapeHtml(detailsText)}</span></td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const totalPages = pagination.total_pages || 1;
|
||||||
|
const currentPage = pagination.page || auditPage;
|
||||||
|
html += `
|
||||||
|
<div class="pagination">
|
||||||
|
<button onclick="goAuditPage(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>Precedente</button>
|
||||||
|
<span class="page-info">Pagina ${currentPage} di ${totalPages}</span>
|
||||||
|
<button onclick="goAuditPage(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>Successiva</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goAuditPage(page) {
|
||||||
|
if (page < 1 || page > auditTotalPages) return;
|
||||||
|
auditPage = page;
|
||||||
|
loadAuditLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// TAB 4: ISO 27001 Mapping
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
async function loadIsoMapping() {
|
||||||
|
const container = document.getElementById('iso-container');
|
||||||
|
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento mapping ISO 27001...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.getIsoMapping();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
renderIsoMapping(result.data);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4></div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIsoMapping(mapping) {
|
||||||
|
const container = document.getElementById('iso-container');
|
||||||
|
|
||||||
|
if (!mapping || mapping.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Nessun mapping disponibile</h4></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Mapping NIS2 - ISO 27001:2022</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p style="font-size:0.85rem; color:var(--gray-500); margin-bottom:20px;">
|
||||||
|
Corrispondenza tra gli articoli della Direttiva NIS2 e i controlli dello standard ISO/IEC 27001:2022.
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
mapping.forEach(m => {
|
||||||
|
const isoControls = (m.iso27001 || '').split(',').map(c => c.trim()).filter(c => c);
|
||||||
|
const controlBadges = isoControls.map(c => `<span class="iso-control-badge">${escapeHtml(c)}</span>`).join('');
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="iso-mapping-row">
|
||||||
|
<span class="iso-nis2-badge">Art. ${escapeHtml(m.nis2)}</span>
|
||||||
|
<span class="iso-arrow">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||||
|
</span>
|
||||||
|
<div class="iso-controls">${controlBadges}</div>
|
||||||
|
<span class="iso-title">${escapeHtml(m.title || '')}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div></div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initial: no auto-load, user triggers report ─────────
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1120
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,725 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Formazione - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="/nis2/css/style.css">
|
||||||
|
<style>
|
||||||
|
/* ── Tab Navigation ─────────────────────────────────────── */
|
||||||
|
.tab-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid var(--gray-200);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.tab-nav button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.tab-nav button:hover {
|
||||||
|
color: var(--gray-700);
|
||||||
|
background: var(--gray-50);
|
||||||
|
}
|
||||||
|
.tab-nav button.active {
|
||||||
|
color: var(--primary);
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
}
|
||||||
|
.tab-panel { display: none; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* ── Course Cards Grid ──────────────────────────────────── */
|
||||||
|
.courses-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.course-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
padding: 20px;
|
||||||
|
transition: box-shadow var(--transition);
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.course-card:hover {
|
||||||
|
box-shadow: var(--card-shadow-hover);
|
||||||
|
}
|
||||||
|
.course-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.course-card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-800);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.course-card-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-grow: 1;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.course-card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.course-meta-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
background: var(--gray-100);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.course-meta-item svg { width: 14px; height: 14px; }
|
||||||
|
.badge-mandatory {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Assignments Table ──────────────────────────────────── */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
.data-table thead {
|
||||||
|
background: var(--gray-50);
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-500);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--gray-700);
|
||||||
|
border-bottom: 1px solid var(--gray-100);
|
||||||
|
}
|
||||||
|
.data-table tr:last-child td { border-bottom: none; }
|
||||||
|
.data-table tr:hover td { background: var(--gray-50); }
|
||||||
|
|
||||||
|
/* ── Status Badges ──────────────────────────────────────── */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.status-badge.assigned { background: var(--gray-100); color: var(--gray-600); }
|
||||||
|
.status-badge.in_progress { background: var(--info-bg); color: var(--info); }
|
||||||
|
.status-badge.completed { background: var(--secondary-bg); color: var(--secondary); }
|
||||||
|
.status-badge.overdue { background: var(--danger-bg); color: var(--danger); }
|
||||||
|
|
||||||
|
/* ── Compliance Percentage Bar ──────────────────────────── */
|
||||||
|
.compliance-bar-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.compliance-bar {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--gray-200);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.compliance-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.compliance-pct {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 45px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.compliance-pct.high { color: var(--secondary); }
|
||||||
|
.compliance-pct.medium { color: var(--warning); }
|
||||||
|
.compliance-pct.low { color: var(--danger); }
|
||||||
|
|
||||||
|
/* ── Spinner/Loading ────────────────────────────────────── */
|
||||||
|
.loading-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
.loading-state .spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--gray-200);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Empty State ────────────────────────────────────────── */
|
||||||
|
.empty-state-box {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
.empty-state-box svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.empty-state-box h4 {
|
||||||
|
color: var(--gray-600);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.courses-grid { grid-template-columns: 1fr; }
|
||||||
|
.tab-nav button { padding: 10px 14px; font-size: 0.82rem; }
|
||||||
|
.data-table { font-size: 0.8rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar" id="sidebar"></aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="content-header">
|
||||||
|
<h2>Formazione</h2>
|
||||||
|
<div class="content-header-actions">
|
||||||
|
<span class="text-muted" style="font-size:0.8rem; margin-right:8px;">Art. 20 NIS2</span>
|
||||||
|
<button class="btn btn-primary" onclick="showCreateCourseModal()">+ Nuovo Corso</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-body">
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="tab-nav">
|
||||||
|
<button class="active" onclick="switchTab('courses', this)">Corsi</button>
|
||||||
|
<button onclick="switchTab('assignments', this)">Le Mie Assegnazioni</button>
|
||||||
|
<button onclick="switchTab('compliance', this)">Compliance Formativa</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Corsi -->
|
||||||
|
<div class="tab-panel active" id="tab-courses">
|
||||||
|
<div class="courses-grid" id="courses-grid">
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Caricamento corsi...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Le Mie Assegnazioni -->
|
||||||
|
<div class="tab-panel" id="tab-assignments">
|
||||||
|
<div id="assignments-container">
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Caricamento assegnazioni...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Compliance Formativa -->
|
||||||
|
<div class="tab-panel" id="tab-compliance">
|
||||||
|
<div style="display:flex; justify-content:flex-end; margin-bottom:16px;">
|
||||||
|
<button class="btn btn-primary" onclick="showAssignCourseModal()">Assegna Corso</button>
|
||||||
|
</div>
|
||||||
|
<div id="compliance-container">
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Caricamento dati compliance...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/nis2/js/api.js"></script>
|
||||||
|
<script src="/nis2/js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── Auth & Init ─────────────────────────────────────────
|
||||||
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
loadSidebar();
|
||||||
|
|
||||||
|
// ── Labels ──────────────────────────────────────────────
|
||||||
|
const roleLabels = {
|
||||||
|
all: 'Tutti',
|
||||||
|
board_member: 'Organi di Gestione',
|
||||||
|
compliance_manager: 'Compliance Manager',
|
||||||
|
employee: 'Dipendente',
|
||||||
|
technical: 'Tecnico'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
assigned: 'Assegnato',
|
||||||
|
in_progress: 'In Corso',
|
||||||
|
completed: 'Completato',
|
||||||
|
overdue: 'Scaduto'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────────────────
|
||||||
|
let allCourses = [];
|
||||||
|
let allAssignments = [];
|
||||||
|
let allCompliance = [];
|
||||||
|
|
||||||
|
// ── Tab Switch ──────────────────────────────────────────
|
||||||
|
function switchTab(tabId, btn) {
|
||||||
|
document.querySelectorAll('.tab-nav button').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById('tab-' + tabId).classList.add('active');
|
||||||
|
|
||||||
|
if (tabId === 'courses' && allCourses.length === 0) loadCourses();
|
||||||
|
if (tabId === 'assignments') loadAssignments();
|
||||||
|
if (tabId === 'compliance') loadCompliance();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load Courses ────────────────────────────────────────
|
||||||
|
async function loadCourses() {
|
||||||
|
const container = document.getElementById('courses-grid');
|
||||||
|
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento corsi...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.listCourses();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
allCourses = result.data;
|
||||||
|
renderCourses(allCourses);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4><p>' + escapeHtml(result.message || '') + '</p></div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4><p>Impossibile caricare i corsi.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCourses(courses) {
|
||||||
|
const container = document.getElementById('courses-grid');
|
||||||
|
|
||||||
|
if (!courses || courses.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state-box" style="grid-column: 1/-1;">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838L7.667 9.088l1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3z"/></svg>
|
||||||
|
<h4>Nessun corso disponibile</h4>
|
||||||
|
<p>Crea il primo corso di formazione per iniziare.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
courses.forEach(course => {
|
||||||
|
const mandatory = course.is_mandatory == 1;
|
||||||
|
html += `
|
||||||
|
<div class="course-card">
|
||||||
|
<div class="course-card-header">
|
||||||
|
<h4 class="course-card-title">${escapeHtml(course.title)}</h4>
|
||||||
|
${mandatory ? '<span class="badge-mandatory">Obbligatorio</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<p class="course-card-desc">${escapeHtml(course.description || 'Nessuna descrizione disponibile.')}</p>
|
||||||
|
<div class="course-card-meta">
|
||||||
|
${course.duration_minutes ? `
|
||||||
|
<span class="course-meta-item">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
|
||||||
|
${course.duration_minutes} min
|
||||||
|
</span>
|
||||||
|
` : ''}
|
||||||
|
<span class="course-meta-item">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/></svg>
|
||||||
|
${roleLabels[course.target_role] || course.target_role || 'Tutti'}
|
||||||
|
</span>
|
||||||
|
${course.nis2_article ? `
|
||||||
|
<span class="course-meta-item">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>
|
||||||
|
Art. ${escapeHtml(course.nis2_article)}
|
||||||
|
</span>
|
||||||
|
` : ''}
|
||||||
|
${course.passing_score ? `
|
||||||
|
<span class="course-meta-item">
|
||||||
|
Soglia: ${course.passing_score}%
|
||||||
|
</span>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create Course Modal ─────────────────────────────────
|
||||||
|
function showCreateCourseModal() {
|
||||||
|
const roleOptions = Object.entries(roleLabels).map(([val, label]) =>
|
||||||
|
`<option value="${val}">${label}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
showModal('Nuovo Corso di Formazione', `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Titolo *</label>
|
||||||
|
<input type="text" class="form-input" id="course-title" placeholder="Es: Fondamenti Cybersecurity NIS2" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Descrizione</label>
|
||||||
|
<textarea class="form-input" id="course-description" rows="3" placeholder="Descrizione del corso..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Ruolo Destinatario</label>
|
||||||
|
<select class="form-select" id="course-target-role">${roleOptions}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Durata (minuti)</label>
|
||||||
|
<input type="number" class="form-input" id="course-duration" min="1" placeholder="60">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Articolo NIS2</label>
|
||||||
|
<input type="text" class="form-input" id="course-nis2-article" placeholder="Es: Art. 20">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Punteggio Minimo (%)</label>
|
||||||
|
<input type="number" class="form-input" id="course-passing-score" min="0" max="100" value="70">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:8px;">
|
||||||
|
<label style="display:flex; align-items:center; gap:8px; cursor:pointer;">
|
||||||
|
<input type="checkbox" id="course-mandatory">
|
||||||
|
<span class="form-label" style="margin:0;">Corso obbligatorio</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`, {
|
||||||
|
size: 'lg',
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||||||
|
<button class="btn btn-primary" onclick="createCourse()">Crea Corso</button>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCourse() {
|
||||||
|
const title = document.getElementById('course-title').value.trim();
|
||||||
|
if (!title) {
|
||||||
|
showNotification('Il titolo e\' obbligatorio.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title: title,
|
||||||
|
description: document.getElementById('course-description').value.trim(),
|
||||||
|
target_role: document.getElementById('course-target-role').value,
|
||||||
|
is_mandatory: document.getElementById('course-mandatory').checked ? 1 : 0,
|
||||||
|
duration_minutes: parseInt(document.getElementById('course-duration').value) || null,
|
||||||
|
nis2_article: document.getElementById('course-nis2-article').value.trim() || null,
|
||||||
|
passing_score: parseInt(document.getElementById('course-passing-score').value) || 70,
|
||||||
|
};
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
showNotification('Creazione corso in corso...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.post('/training/courses', data);
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Corso creato con successo!', 'success');
|
||||||
|
loadCourses();
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nella creazione del corso.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load Assignments ────────────────────────────────────
|
||||||
|
async function loadAssignments() {
|
||||||
|
const container = document.getElementById('assignments-container');
|
||||||
|
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento assegnazioni...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.getMyTraining();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
allAssignments = result.data;
|
||||||
|
renderAssignments(allAssignments);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4></div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssignments(assignments) {
|
||||||
|
const container = document.getElementById('assignments-container');
|
||||||
|
|
||||||
|
if (!assignments || assignments.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state-box">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5z" clip-rule="evenodd"/></svg>
|
||||||
|
<h4>Nessuna assegnazione</h4>
|
||||||
|
<p>Non hai ancora corsi assegnati.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Corso</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th>Scadenza</th>
|
||||||
|
<th>Punteggio Quiz</th>
|
||||||
|
<th>Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
assignments.forEach(a => {
|
||||||
|
const isOverdue = a.due_date && new Date(a.due_date) < new Date() && a.status !== 'completed';
|
||||||
|
const displayStatus = isOverdue ? 'overdue' : a.status;
|
||||||
|
|
||||||
|
let actionBtn = '';
|
||||||
|
if (displayStatus === 'assigned' || displayStatus === 'overdue') {
|
||||||
|
actionBtn = `<button class="btn btn-primary btn-sm" onclick="updateAssignment(${a.id}, 'in_progress')">Inizia</button>`;
|
||||||
|
} else if (displayStatus === 'in_progress') {
|
||||||
|
actionBtn = `<button class="btn btn-primary btn-sm" onclick="updateAssignment(${a.id}, 'completed')">Completa</button>`;
|
||||||
|
} else {
|
||||||
|
actionBtn = `<span style="color:var(--secondary); font-weight:500; font-size:0.85rem;">Completato</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight:500;">${escapeHtml(a.title || '')}</div>
|
||||||
|
${a.is_mandatory == 1 ? '<span style="font-size:0.75rem; color:var(--danger);">Obbligatorio</span>' : ''}
|
||||||
|
</td>
|
||||||
|
<td><span class="status-badge ${displayStatus}">${statusLabels[displayStatus] || displayStatus}</span></td>
|
||||||
|
<td>${a.due_date ? formatDate(a.due_date) : '-'}</td>
|
||||||
|
<td>${a.quiz_score != null ? a.quiz_score + '%' : '-'}</td>
|
||||||
|
<td>${actionBtn}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAssignment(id, status) {
|
||||||
|
try {
|
||||||
|
const result = await api.put(`/training/assignments/${id}`, { status: status });
|
||||||
|
if (result.success) {
|
||||||
|
const msg = status === 'in_progress' ? 'Corso iniziato!' : 'Corso completato!';
|
||||||
|
showNotification(msg, 'success');
|
||||||
|
loadAssignments();
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nell\'aggiornamento.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load Compliance Status ──────────────────────────────
|
||||||
|
async function loadCompliance() {
|
||||||
|
const container = document.getElementById('compliance-container');
|
||||||
|
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento dati compliance...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.getTrainingCompliance();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
allCompliance = result.data;
|
||||||
|
renderCompliance(allCompliance);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4></div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCompliance(members) {
|
||||||
|
const container = document.getElementById('compliance-container');
|
||||||
|
|
||||||
|
if (!members || members.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state-box">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/></svg>
|
||||||
|
<h4>Nessun membro trovato</h4>
|
||||||
|
<p>Nessun membro dell\'organizzazione trovato.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Ruolo</th>
|
||||||
|
<th>Corsi Completati</th>
|
||||||
|
<th>% Compliance Obbligatoria</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
members.forEach(m => {
|
||||||
|
const assignments = m.assignments || [];
|
||||||
|
const completed = assignments.filter(a => a.status === 'completed').length;
|
||||||
|
const total = assignments.length;
|
||||||
|
const pct = m.mandatory_compliance != null ? m.mandatory_compliance : 100;
|
||||||
|
const pctClass = pct >= 80 ? 'high' : pct >= 50 ? 'medium' : 'low';
|
||||||
|
const barColor = pct >= 80 ? 'var(--secondary)' : pct >= 50 ? 'var(--warning)' : 'var(--danger)';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;">${escapeHtml(m.full_name || '-')}</td>
|
||||||
|
<td>${escapeHtml(m.email || '-')}</td>
|
||||||
|
<td>${escapeHtml(m.role || '-')}</td>
|
||||||
|
<td>${completed} / ${total}</td>
|
||||||
|
<td>
|
||||||
|
<div class="compliance-bar-container">
|
||||||
|
<div class="compliance-bar">
|
||||||
|
<div class="compliance-bar-fill" style="width:${pct}%; background:${barColor};"></div>
|
||||||
|
</div>
|
||||||
|
<span class="compliance-pct ${pctClass}">${pct}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Assign Course Modal ─────────────────────────────────
|
||||||
|
async function showAssignCourseModal() {
|
||||||
|
// Load courses if not already loaded
|
||||||
|
if (allCourses.length === 0) {
|
||||||
|
try {
|
||||||
|
const result = await api.listCourses();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
allCourses = result.data;
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allCourses.length === 0) {
|
||||||
|
showNotification('Nessun corso disponibile. Crea prima un corso.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const courseOptions = allCourses.map(c =>
|
||||||
|
`<option value="${c.id}">${escapeHtml(c.title)}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Load members for selection
|
||||||
|
let membersHtml = '<p class="text-muted">Caricamento membri...</p>';
|
||||||
|
if (allCompliance.length > 0) {
|
||||||
|
membersHtml = allCompliance.map(m => `
|
||||||
|
<label style="display:flex; align-items:center; gap:8px; padding:6px 0; cursor:pointer;">
|
||||||
|
<input type="checkbox" class="assign-user-cb" value="${m.id}">
|
||||||
|
<span>${escapeHtml(m.full_name || m.email)}</span>
|
||||||
|
<span style="color:var(--gray-400); font-size:0.8rem;">(${escapeHtml(m.role || '')})</span>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
showModal('Assegna Corso', `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Corso</label>
|
||||||
|
<select class="form-select" id="assign-course-id">${courseOptions}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Scadenza</label>
|
||||||
|
<input type="date" class="form-input" id="assign-due-date">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Seleziona Utenti</label>
|
||||||
|
<div style="max-height:200px; overflow-y:auto; border:1px solid var(--gray-200); border-radius:var(--border-radius-sm); padding:8px 12px;">
|
||||||
|
${membersHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`, {
|
||||||
|
size: 'lg',
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||||||
|
<button class="btn btn-primary" onclick="doAssignCourse()">Assegna</button>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAssignCourse() {
|
||||||
|
const courseId = document.getElementById('assign-course-id').value;
|
||||||
|
const dueDate = document.getElementById('assign-due-date').value || null;
|
||||||
|
const checkboxes = document.querySelectorAll('.assign-user-cb:checked');
|
||||||
|
const userIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
showNotification('Seleziona almeno un utente.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
showNotification('Assegnazione in corso...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.post('/training/assign', {
|
||||||
|
course_id: parseInt(courseId),
|
||||||
|
user_ids: userIds,
|
||||||
|
due_date: dueDate
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
showNotification(result.message || 'Corso assegnato con successo!', 'success');
|
||||||
|
loadCompliance();
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nell\'assegnazione.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initial Load ────────────────────────────────────────
|
||||||
|
loadCourses();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user