Evita la "sessione scaduta" e i deploy che restano invisibili all'utente. - api.js: refresh PROATTIVO dell'access token ~2 min prima della scadenza (scheduleTokenRefresh su exp del JWT, riprogrammato ad ogni setTokens) + ensureFreshToken() per rinnovo immediato al ritorno sulla scheda. Con refresh a 7gg l'utente resta loggato senza mai vedere il login. - common.js: watch versione (poll version.json ogni 5 min + su visibilitychange) → banner "È disponibile una nuova versione — Aggiorna" (reload) quando cambia; nudge service worker (registration.update); su visibilitychange chiama anche api.ensureFreshToken(). Nessun hard-refresh manuale. - Cache-buster api/common ?v=20260729b. version 1.25.12. Solo frontend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
551 lines
33 KiB
HTML
551 lines
33 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="it">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>Compliance Journey · NIS2 Agile</title>
|
||
<!-- UI V3: Inter self-host + design system v3.css (NO Bootstrap Italia / NO FontAwesome CDN / NO Google Fonts). -->
|
||
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
|
||
<link rel="stylesheet" href="/css/v3.css?v=20260627">
|
||
<style>
|
||
/* Stili specifici della pagina Compliance Journey non coperti da v3.css (nessun CDN). */
|
||
:root { --nis2-cyan:#1F5FA8; }
|
||
.text-muted { color: var(--muted); }
|
||
.ic-lg { width:22px; height:22px; }
|
||
|
||
/* ── Journey header ────────────────────────────────── */
|
||
.journey-header {
|
||
background: linear-gradient(135deg, var(--navy-nav) 0%, var(--navy-top) 100%);
|
||
border-radius: var(--radius);
|
||
padding: 24px 28px; color:#fff; margin-bottom:20px;
|
||
display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.journey-score { font-size:2.8rem; font-weight:800; color:#fff; line-height:1; }
|
||
.journey-progress-bar { height:10px; background:rgba(255,255,255,.14); border-radius:5px; margin-top:10px; overflow:hidden; }
|
||
.journey-progress-fill { height:100%; background:linear-gradient(90deg, var(--gold), #ffd76a); border-radius:5px; transition:width 1s ease; }
|
||
.journey-meta { text-align:right; font-size:.78rem; opacity:.65; }
|
||
.journey-meta strong { display:block; font-size:.9rem; opacity:1; }
|
||
|
||
/* ── Next action banner ────────────────────────────── */
|
||
.next-action-banner {
|
||
background: linear-gradient(135deg, #0c4a6e, #075985); color:#fff; border-radius:var(--radius);
|
||
padding:14px 20px; display:flex; align-items:center; gap:14px; margin-bottom:20px;
|
||
border-left:4px solid var(--gold);
|
||
}
|
||
.next-action-banner .na-icon { color:var(--gold); flex-shrink:0; width:24px; height:24px; }
|
||
.next-action-banner .na-label { font-size:.72rem; opacity:.75; text-transform:uppercase; letter-spacing:.05em; }
|
||
.next-action-banner .na-text { font-size:.95rem; font-weight:600; }
|
||
.next-action-banner a { color:#fff; text-decoration:none; }
|
||
.next-action-banner a:hover { text-decoration:underline; }
|
||
|
||
/* ── Phase roadmap ─────────────────────────────────── */
|
||
.phase-roadmap { display:flex; align-items:stretch; margin-bottom:20px; overflow-x:auto; padding-bottom:2px; gap:0; }
|
||
.phase-item { flex:1; min-width:120px; position:relative; cursor:pointer; }
|
||
.phase-box { padding:14px 16px 12px 18px; color:#fff; height:100%; display:flex; flex-direction:column; justify-content:center; transition:filter .2s, box-shadow .2s; position:relative; }
|
||
.phase-item:first-child .phase-box { border-radius: var(--radius) 0 0 var(--radius); }
|
||
.phase-item:last-child .phase-box { border-radius: 0 var(--radius) var(--radius) 0; }
|
||
.phase-item:not(:last-child) .phase-box::after {
|
||
content:''; position:absolute; right:-13px; top:50%; transform:translateY(-50%); width:0; height:0;
|
||
border-top:28px solid transparent; border-bottom:28px solid transparent; border-left:13px solid var(--ph-color); z-index:3;
|
||
}
|
||
.phase-item:not(:last-child)::after {
|
||
content:''; position:absolute; right:-13px; top:50%; transform:translateY(-50%); width:0; height:0;
|
||
border-top:28px solid transparent; border-bottom:28px solid transparent; border-left:13px solid var(--bg); z-index:2;
|
||
}
|
||
.phase-item.active .phase-box { filter:brightness(1.18); box-shadow:0 4px 18px rgba(0,0,0,.25); z-index:1; }
|
||
.phase-item:not(.active):hover .phase-box { filter:brightness(1.08); }
|
||
.phase-item.todo .phase-box { opacity:.6; }
|
||
.phase-num { font-size:.62rem; font-weight:600; text-transform:uppercase; letter-spacing:.06em; opacity:.7; margin-bottom:2px; }
|
||
.phase-name { font-size:.78rem; font-weight:700; line-height:1.2; }
|
||
.phase-status-row { display:flex; align-items:center; gap:5px; margin-top:5px; }
|
||
.phase-status-icon { width:14px; height:14px; }
|
||
.phase-pct { font-size:.7rem; opacity:.85; }
|
||
.phase-mini-bar { height:3px; background:rgba(255,255,255,.2); border-radius:2px; margin-top:5px; overflow:hidden; }
|
||
.phase-mini-fill { height:100%; background:rgba(255,255,255,.8); border-radius:2px; }
|
||
|
||
/* ── Phase detail ──────────────────────────────────── */
|
||
.phase-detail-header { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:16px; }
|
||
.phase-detail-badge { display:inline-flex; align-items:center; gap:6px; padding:5px 14px; border-radius:20px; font-size:.78rem; font-weight:700; color:#fff; }
|
||
.phase-detail-badge i { width:15px; height:15px; }
|
||
.phase-detail-desc { font-size:.85rem; color:var(--muted); margin-left:auto; }
|
||
|
||
/* ── Step cards ────────────────────────────────────── */
|
||
.step-cards { display:grid; grid-template-columns:repeat(auto-fill, minmax(220px,1fr)); gap:14px; }
|
||
.step-card { background:#fff; border-radius:var(--radius); padding:16px; border:1.5px solid var(--line); cursor:pointer; transition:box-shadow .15s, border-color .15s, transform .15s; text-decoration:none; display:block; color:inherit; }
|
||
.step-card:hover { box-shadow:0 6px 20px rgba(16,40,80,.12); border-color:#9FBEDD; transform:translateY(-2px); }
|
||
.step-card-top { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; }
|
||
.step-icon { width:38px; height:38px; border-radius:10px; display:flex; align-items:center; justify-content:center; }
|
||
.step-icon i { width:18px; height:18px; }
|
||
.step-badge { font-size:.67rem; padding:2px 9px; border-radius:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; }
|
||
.badge-done { background:#E6F4EC; color:#1E7A4E; }
|
||
.badge-partial { background:#FBF1E2; color:#9A6418; }
|
||
.badge-todo { background:#EEF2F7; color:#5b6b80; }
|
||
.badge-active { background:#E7F0FB; color:#1F5FA8; }
|
||
.step-card h4 { font-size:.9rem; font-weight:700; margin:0 0 3px; color:var(--ink); }
|
||
.step-card p { font-size:.78rem; color:var(--muted); line-height:1.45; margin:0 0 12px; }
|
||
.step-metric { font-size:1.5rem; font-weight:800; color:var(--ink); line-height:1; }
|
||
.step-metric-lbl { font-size:.72rem; color:var(--muted); margin-top:1px; }
|
||
.step-cta { display:inline-flex; align-items:center; gap:5px; margin-top:10px; font-size:.74rem; font-weight:600; color:var(--c-blue); }
|
||
.step-cta i { width:13px; height:13px; }
|
||
|
||
/* ── Legend ────────────────────────────────────────── */
|
||
.legend-row { display:flex; gap:16px; flex-wrap:wrap; margin-bottom:20px; font-size:.8rem; color:var(--muted); }
|
||
.legend-item { display:flex; align-items:center; gap:6px; }
|
||
.legend-item i { width:15px; height:15px; }
|
||
|
||
@keyframes fadeSlideIn { from { opacity:0; transform:translateY(-6px); } to { opacity:1; transform:translateY(0); } }
|
||
.phase-detail-anim { animation: fadeSlideIn .2s ease; }
|
||
@media (prefers-reduced-motion:reduce){ .phase-detail-anim{animation:none} .journey-progress-fill{transition:none} }
|
||
|
||
@media (max-width:640px) {
|
||
.phase-name { font-size:.7rem; }
|
||
.phase-num { display:none; }
|
||
.journey-header { flex-direction:column; align-items:flex-start; }
|
||
.phase-detail-desc { margin-left:0; }
|
||
}
|
||
</style>
|
||
<!-- PWA:start -->
|
||
<link rel="manifest" href="/manifest.webmanifest">
|
||
<meta name="theme-color" content="#0066CC">
|
||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
|
||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
|
||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
|
||
<meta name="application-name" content="NIS2 Agile">
|
||
<script src="/js/pwa.js?v=20260614" defer></script>
|
||
<!-- PWA:end -->
|
||
</head>
|
||
<body>
|
||
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
|
||
<div id="topnav"></div>
|
||
|
||
<main class="wrap">
|
||
<nav class="crumb" aria-label="breadcrumb">
|
||
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
|
||
<i data-lucide="chevron-right" class="ic"></i><span>Compliance Journey</span>
|
||
</nav>
|
||
<div class="page-head">
|
||
<div>
|
||
<h1>Compliance Journey</h1>
|
||
<p class="sub">Il percorso di conformità NIS2 in 6 fasi, con stato in tempo reale</p>
|
||
</div>
|
||
<div class="date"><i data-lucide="building" class="ic"></i><span id="org-name-header"></span></div>
|
||
</div>
|
||
|
||
<div id="page-body">
|
||
<div class="v3-loading"><span class="v3-spin"></span>Caricamento del percorso di conformità…</div>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
|
||
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → init pagina. -->
|
||
<script src="/js/api.js?v=20260729b"></script>
|
||
<script src="/js/common.js?v=20260729b"></script>
|
||
<script src="/vendor/lucide/lucide.min.js"></script>
|
||
<script src="/js/topnav-v3.js?v=20260729"></script>
|
||
<script src="/js/i18n.js?v=20260729"></script>
|
||
<script src="/js/help.js?v=20260729b"></script>
|
||
<script>
|
||
// ── Auth + chrome + i18n + help ──────────────────────────────
|
||
if (!checkAuth()) throw new Error('Not authenticated');
|
||
loadSidebar(); // = loadTopnavV3() (topnav-v3.js)
|
||
if (typeof I18n !== 'undefined') I18n.init();
|
||
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
|
||
|
||
// Re-render delle icone Lucide (chrome + markup dinamico)
|
||
function drawIcons(){ if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch(e){} } }
|
||
drawIcons();
|
||
|
||
// ── Dati globali ────────────────────────────────────────────
|
||
let G = {};
|
||
let activePhase = 0;
|
||
|
||
// ── Definizione fasi (icone = nomi Lucide self-host) ─────────
|
||
const PHASES = [
|
||
{
|
||
id: 'preparazione', num: 1, name: 'Preparazione',
|
||
color: '#6366f1', icon: 'rocket',
|
||
desc: 'Organizzazione, team e classificazione NIS2',
|
||
steps: [
|
||
{ id: 'onboarding', name: 'Onboarding NIS2', icon: 'rocket', desc: 'Classificazione settore, dimensione e tipo soggetto NIS2', href: 'onboarding.html', mk: 'onboarding_done' },
|
||
{ id: 'org', name: 'Organizzazione', icon: 'building', desc: 'Dati aziendali, P.IVA, classificazione completata', href: 'settings.html', mk: 'org_setup' },
|
||
{ id: 'team', name: 'Team & Ruoli', icon: 'users', desc: 'Membri del team con ruoli CISO, Admin, Auditor assegnati', href: 'settings.html', mk: 'members_count' },
|
||
]
|
||
},
|
||
{
|
||
id: 'valutazione', num: 2, name: 'Valutazione',
|
||
color: '#0891b2', icon: 'search-check',
|
||
desc: 'Gap analysis, asset e supply chain',
|
||
steps: [
|
||
{ id: 'assessment', name: 'Gap Assessment', icon: 'clipboard-check', desc: '80 domande Art.21 NIS2 su 10 categorie di misure di sicurezza', href: 'assessment.html', mk: 'assessment_pct' },
|
||
{ id: 'assets', name: 'Inventario Asset', icon: 'server', desc: 'Censimento asset critici, classificazione e mappa dipendenze', href: 'assets.html', mk: 'assets_count' },
|
||
{ id: 'supply', name: 'Supply Chain', icon: 'link', desc: 'Fornitori critici, valutazione rischio e certificazioni', href: 'supply-chain.html', mk: 'suppliers_count' },
|
||
]
|
||
},
|
||
{
|
||
id: 'rischi', num: 3, name: 'Rischi',
|
||
color: '#dc2626', icon: 'shield-half',
|
||
desc: 'Identificazione, valutazione e trattamento',
|
||
steps: [
|
||
{ id: 'risks', name: 'Risk Register', icon: 'triangle-alert', desc: 'Identificazione e scoring rischi su scala ISO 27005 (5×5)', href: 'risks.html', mk: 'risks_count' },
|
||
{ id: 'treatments', name: 'Piano Trattamento', icon: 'shield', desc: 'Azioni di mitigazione, accettazione o trasferimento rischi', href: 'risks.html', mk: 'treatments_pct' },
|
||
{ id: 'ai_risk', name: 'AI Risk Suggest', icon: 'wand-sparkles', desc: 'Suggerimenti rischi generati da AI per settore e asset', href: 'risks.html', mk: 'ai_risks_done' },
|
||
]
|
||
},
|
||
{
|
||
id: 'implementazione', num: 4, name: 'Implementazione',
|
||
color: '#16a34a', icon: 'settings',
|
||
desc: 'Policy, formazione e controlli di sicurezza',
|
||
steps: [
|
||
{ id: 'policies', name: 'Policy', icon: 'file-lock', desc: 'Redazione, revisione AI e approvazione policy NIS2', href: 'policies.html', mk: 'policies_approved' },
|
||
{ id: 'training', name: 'Formazione', icon: 'graduation-cap', desc: 'Corsi obbligatori assegnati e completati dal personale', href: 'training.html', mk: 'training_pct' },
|
||
{ id: 'controls', name: 'Controlli Audit', icon: 'list-checks', desc: 'Implementazione controlli ISO 27001 / misure Art.21 NIS2', href: 'reports.html', mk: 'controls_pct' },
|
||
]
|
||
},
|
||
{
|
||
id: 'monitoraggio', num: 5, name: 'Monitoraggio',
|
||
color: '#d97706', icon: 'eye',
|
||
desc: 'Incidenti, non-conformità e normative',
|
||
steps: [
|
||
{ id: 'incidents', name: 'Incidenti Art.23', icon: 'bell', desc: 'Gestione e notifica CSIRT: 24h early warning, 72h, 1 mese', href: 'incidents.html', mk: 'incidents_count' },
|
||
{ id: 'ncr', name: 'NCR / CAPA', icon: 'circle-x', desc: 'Non-conformità rilevate e azioni correttive in corso', href: 'reports.html', mk: 'ncr_open' },
|
||
{ id: 'normative', name: 'Aggiornamenti NIS2', icon: 'newspaper', desc: 'Feed ACN, DORA, D.Lgs. 138/2024 con conferma di lettura', href: 'normative.html', mk: 'normative_pending' },
|
||
{ id: 'wb', name: 'Segnalazioni', icon: 'eye-off', desc: 'Canale whistleblowing anonimo Art.32 NIS2', href: 'whistleblowing.html', mk: 'wb_open' },
|
||
]
|
||
},
|
||
{
|
||
id: 'reportistica', num: 6, name: 'Reportistica',
|
||
color: '#7c3aed', icon: 'line-chart',
|
||
desc: 'Report esecutivi, audit chain e export',
|
||
steps: [
|
||
{ id: 'exec_report', name: 'Report Compliance', icon: 'file-chart-column', desc: 'Report esecutivo HTML stampabile come PDF per board e auditor', href: 'reports.html', mk: 'report_gen' },
|
||
{ id: 'audit_chain', name: 'Audit Chain', icon: 'link', desc: 'Log immutabile con hash chain SHA-256, verifica integrità', href: 'reports.html', mk: 'audit_records' },
|
||
{ id: 'export', name: 'Export Certificato', icon: 'file-output', desc: 'Export CSV/JSON con firma SHA-256 per conservazione prove', href: 'reports.html', mk: 'export_done' },
|
||
]
|
||
}
|
||
];
|
||
|
||
// ── Caricamento dati parallelo ───────────────────────────────
|
||
async function loadAll() {
|
||
const [ov, sc, al, rl, pl, asl, sl, ts, ac] = await Promise.allSettled([
|
||
api.getDashboardOverview(),
|
||
api.getComplianceScore(),
|
||
api.get('/assessments/list'),
|
||
api.get('/risks/list'),
|
||
api.get('/policies/list'),
|
||
api.get('/assets/list'),
|
||
api.get('/supply-chain/list'),
|
||
api.get('/training/compliance-status'),
|
||
api.get('/audit/controls'),
|
||
]);
|
||
|
||
const val = r => r.status === 'fulfilled' ? (r.value?.data || {}) : {};
|
||
const o = val(ov), s = val(sc), a = val(al);
|
||
const rr = val(rl), p = val(pl), as = val(asl);
|
||
const sp = val(sl), tr = val(ts), ct = val(ac);
|
||
|
||
const assessments = Array.isArray(a.assessments) ? a.assessments : [];
|
||
const risks = Array.isArray(rr.risks) ? rr.risks : [];
|
||
const policies = Array.isArray(p.policies) ? p.policies : [];
|
||
const assets = Array.isArray(as.assets) ? as.assets : [];
|
||
const suppliers = Array.isArray(sp.suppliers) ? sp.suppliers : [];
|
||
const controls = Array.isArray(ct.controls) ? ct.controls : [];
|
||
|
||
const completedA = assessments.filter(a => a.status === 'completed');
|
||
const assessPct = assessments.length ? Math.round(completedA.length / assessments.length * 100) : 0;
|
||
const approvedPol = policies.filter(p => p.status === 'approved' || p.status === 'published').length;
|
||
const implCtrls = controls.filter(c => c.implementation_status === 'implemented').length;
|
||
const controlsPct = controls.length ? Math.round(implCtrls / controls.length * 100) : 0;
|
||
const trainingPct = tr.completion_rate != null ? Math.round(tr.completion_rate)
|
||
: (tr.completed && tr.total ? Math.round(tr.completed / tr.total * 100) : 0);
|
||
const openRisks = risks.filter(r => !r.deleted_at).length;
|
||
const withTreat = risks.filter(r => r.treatments_count > 0 || r.treatment_status).length;
|
||
const treatPct = openRisks ? Math.round(withTreat / openRisks * 100) : 0;
|
||
const score = Math.round(s.avg_implementation || s.score || o.compliance_score || 0);
|
||
|
||
G = {
|
||
complianceScore: score,
|
||
onboarding_done: 1,
|
||
org_setup: 1,
|
||
members_count: o.members_count || '—',
|
||
assessments_total: assessments.length,
|
||
assessment_pct: assessPct,
|
||
assets_count: assets.length,
|
||
suppliers_count: suppliers.length,
|
||
risks_count: openRisks,
|
||
treatments_pct: treatPct,
|
||
ai_risks_done: risks.filter(r => r.ai_generated).length,
|
||
policies_approved: approvedPol,
|
||
policies_total: policies.length,
|
||
training_pct: trainingPct,
|
||
controls_pct: controlsPct,
|
||
controls_total: controls.length,
|
||
incidents_count: o.active_incidents || 0,
|
||
ncr_open: 0,
|
||
normative_pending: 0,
|
||
wb_open: 0,
|
||
report_gen: completedA.length,
|
||
audit_records: null,
|
||
export_done: null,
|
||
};
|
||
|
||
// Seleziona automaticamente la fase "attiva" (prima non completata)
|
||
activePhase = computeActivePhase();
|
||
renderPage();
|
||
}
|
||
|
||
// ── Logica status ────────────────────────────────────────────
|
||
function phaseStatus(id) {
|
||
switch (id) {
|
||
case 'preparazione': return 'done';
|
||
case 'valutazione':
|
||
if (!G.assessments_total && !G.assets_count) return 'todo';
|
||
return G.assessment_pct >= 100 && G.assets_count > 0 ? 'done' : 'partial';
|
||
case 'rischi':
|
||
if (!G.risks_count) return 'todo';
|
||
return G.treatments_pct >= 80 ? 'done' : 'partial';
|
||
case 'implementazione':
|
||
if (!G.policies_total && !G.controls_total) return 'todo';
|
||
return G.controls_pct >= 80 && G.training_pct >= 80 ? 'done' : 'partial';
|
||
case 'monitoraggio': return 'active';
|
||
case 'reportistica': return G.report_gen > 0 ? 'partial' : 'todo';
|
||
default: return 'todo';
|
||
}
|
||
}
|
||
|
||
function phasePct(id) {
|
||
switch (id) {
|
||
case 'preparazione': return 100;
|
||
case 'valutazione': return Math.round((G.assessment_pct + (G.assets_count > 0 ? 100 : 0)) / 2);
|
||
case 'rischi': return G.risks_count > 0 ? Math.max(10, G.treatments_pct) : 0;
|
||
case 'implementazione': return Math.round(((G.controls_pct || 0) + (G.training_pct || 0) + (G.policies_approved > 0 ? 60 : 0)) / 3);
|
||
case 'monitoraggio': return 100;
|
||
case 'reportistica': return G.report_gen > 0 ? 40 : 0;
|
||
default: return 0;
|
||
}
|
||
}
|
||
|
||
function stepStatus(mk) {
|
||
switch (mk) {
|
||
case 'onboarding_done':
|
||
case 'org_setup': return 'done';
|
||
case 'members_count': return 'done';
|
||
case 'assessment_pct': return G.assessments_total ? (G.assessment_pct >= 100 ? 'done' : 'partial') : 'todo';
|
||
case 'assets_count': return G.assets_count > 0 ? 'done' : 'todo';
|
||
case 'suppliers_count': return G.suppliers_count > 0 ? 'partial' : 'todo';
|
||
case 'risks_count': return G.risks_count > 0 ? 'partial' : 'todo';
|
||
case 'treatments_pct': return G.treatments_pct >= 80 ? 'done' : (G.treatments_pct > 0 ? 'partial' : 'todo');
|
||
case 'ai_risks_done': return G.ai_risks_done > 0 ? 'done' : 'todo';
|
||
case 'policies_approved':return G.policies_approved >= 3 ? 'done' : (G.policies_approved > 0 ? 'partial' : 'todo');
|
||
case 'training_pct': return G.training_pct >= 80 ? 'done' : (G.training_pct > 0 ? 'partial' : 'todo');
|
||
case 'controls_pct': return G.controls_pct >= 80 ? 'done' : (G.controls_pct > 0 ? 'partial' : 'todo');
|
||
case 'incidents_count': return 'active';
|
||
case 'ncr_open': return 'active';
|
||
case 'normative_pending':return 'active';
|
||
case 'wb_open': return 'active';
|
||
default: return 'partial';
|
||
}
|
||
}
|
||
|
||
function stepMetric(mk) {
|
||
const m = {
|
||
onboarding_done: { val: '✓', lbl: 'configurato' },
|
||
org_setup: { val: '✓', lbl: 'configurato' },
|
||
members_count: { val: G.members_count, lbl: 'membri' },
|
||
assessment_pct: G.assessments_total
|
||
? { val: G.assessment_pct + '%', lbl: 'completato' }
|
||
: { val: '0', lbl: 'assessment' },
|
||
assets_count: { val: G.assets_count || 0, lbl: 'asset censiti' },
|
||
suppliers_count: { val: G.suppliers_count || 0, lbl: 'fornitori' },
|
||
risks_count: { val: G.risks_count || 0, lbl: 'rischi aperti' },
|
||
treatments_pct: G.risks_count
|
||
? { val: G.treatments_pct + '%', lbl: 'con trattamento' }
|
||
: { val: '—', lbl: '' },
|
||
ai_risks_done: { val: G.ai_risks_done || 0, lbl: 'suggeriti da AI' },
|
||
policies_approved: { val: G.policies_approved || 0, lbl: 'approvate' },
|
||
training_pct: { val: (G.training_pct || 0) + '%', lbl: 'completato' },
|
||
controls_pct: { val: (G.controls_pct || 0) + '%', lbl: 'implementati' },
|
||
incidents_count: { val: G.incidents_count || 0, lbl: 'attivi' },
|
||
ncr_open: { val: G.ncr_open || 0, lbl: 'aperte' },
|
||
normative_pending: { val: G.normative_pending || 0, lbl: 'da leggere' },
|
||
wb_open: { val: G.wb_open || 0, lbl: 'in gestione' },
|
||
report_gen: { val: G.report_gen || 0, lbl: 'assessment completati' },
|
||
audit_records: { val: '—', lbl: 'record' },
|
||
export_done: { val: '—', lbl: 'export' },
|
||
};
|
||
return m[mk] || { val: '—', lbl: '' };
|
||
}
|
||
|
||
const ST_ICON = { done: 'circle-check', partial: 'circle-dashed', active: 'circle-dot', todo: 'circle' };
|
||
const ST_LBL = { done: 'Completato', partial: 'In corso', active: 'Attivo', todo: 'Da iniziare' };
|
||
const ST_BADGE = { done: 'badge-done', partial: 'badge-partial', active: 'badge-active', todo: 'badge-todo' };
|
||
|
||
function computeActivePhase() {
|
||
for (let i = 0; i < PHASES.length; i++) {
|
||
const st = phaseStatus(PHASES[i].id);
|
||
if (st === 'partial' || st === 'todo' || st === 'active') return i;
|
||
}
|
||
return PHASES.length - 1;
|
||
}
|
||
|
||
function computeNextAction() {
|
||
if (!G.assessments_total) return { text: 'Avvia il primo Gap Assessment NIS2', link: 'assessment.html', icon: 'clipboard-check' };
|
||
if (G.assessment_pct < 100) return { text: 'Completa il Gap Assessment in corso (' + G.assessment_pct + '%)', link: 'assessment.html', icon: 'clipboard-check' };
|
||
if (!G.assets_count) return { text: 'Censisci gli asset critici dell\'organizzazione', link: 'assets.html', icon: 'server' };
|
||
if (!G.risks_count) return { text: 'Registra i rischi NIS2 nel Risk Register', link: 'risks.html', icon: 'triangle-alert' };
|
||
if (!G.policies_total) return { text: 'Genera la prima policy con AI', link: 'policies.html', icon: 'file-lock' };
|
||
if (G.training_pct < 50) return { text: 'Assegna i corsi di formazione al team', link: 'training.html', icon: 'graduation-cap' };
|
||
if (G.controls_pct < 60) return { text: 'Implementa i controlli di sicurezza Art.21', link: 'reports.html', icon: 'list-checks' };
|
||
return { text: 'Genera il report di compliance per il board', link: 'reports.html', icon: 'line-chart' };
|
||
}
|
||
|
||
// ── Render ───────────────────────────────────────────────────
|
||
function renderPage() {
|
||
const next = computeNextAction();
|
||
const score = G.complianceScore;
|
||
|
||
let html = '';
|
||
|
||
// ── Journey header
|
||
html += `
|
||
<div class="journey-header">
|
||
<div>
|
||
<div style="font-size:0.75rem;opacity:0.65;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:4px;">Progresso Art.21 NIS2</div>
|
||
<div style="display:flex;align-items:baseline;gap:10px;">
|
||
<span class="journey-score">${score}%</span>
|
||
<span style="color:rgba(255,255,255,0.6);font-size:0.82rem;">implementazione misure</span>
|
||
</div>
|
||
<div class="journey-progress-bar" style="max-width:320px;">
|
||
<div class="journey-progress-fill" style="width:${score}%;"></div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:24px;flex-wrap:wrap;">
|
||
${PHASES.map(ph => {
|
||
const pct = phasePct(ph.id);
|
||
const st = phaseStatus(ph.id);
|
||
return `<div style="text-align:center;">
|
||
<div style="font-size:0.65rem;opacity:0.6;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:2px;">${ph.name}</div>
|
||
<div style="font-size:1rem;font-weight:700;color:${st==='done'?'#7ee0a8':st==='todo'?'rgba(255,255,255,0.4)':'var(--gold)'};">${pct}%</div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
<div class="journey-meta">
|
||
<strong>${new Date().toLocaleDateString('it-IT', {day:'numeric',month:'long',year:'numeric'})}</strong>
|
||
Compliance Journey NIS2
|
||
</div>
|
||
</div>`;
|
||
|
||
// ── Next action
|
||
html += `
|
||
<div class="next-action-banner">
|
||
<i data-lucide="${next.icon}" class="na-icon"></i>
|
||
<div>
|
||
<div class="na-label">Prossima azione consigliata</div>
|
||
<div class="na-text"><a href="${next.link}">${next.text} →</a></div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// ── Legend
|
||
html += `
|
||
<div class="legend-row">
|
||
<div class="legend-item"><i data-lucide="circle-check" style="color:var(--c-green);"></i> Completato</div>
|
||
<div class="legend-item"><i data-lucide="circle-dashed" style="color:var(--c-amber);"></i> In corso</div>
|
||
<div class="legend-item"><i data-lucide="circle-dot" style="color:var(--c-blue);"></i> Attivo / Continuo</div>
|
||
<div class="legend-item"><i data-lucide="circle" style="color:#94a3b8;"></i> Da iniziare</div>
|
||
</div>`;
|
||
|
||
// ── Phase roadmap
|
||
html += `<div class="phase-roadmap">`;
|
||
PHASES.forEach((ph, idx) => {
|
||
const st = phaseStatus(ph.id);
|
||
const pct = phasePct(ph.id);
|
||
const isActive = activePhase === idx;
|
||
html += `
|
||
<div class="phase-item ${isActive ? 'active' : ''} ${st}" onclick="selectPhase(${idx})"
|
||
style="--ph-color:${ph.color}${st==='todo'?'99':''};">
|
||
<div class="phase-box" style="background:${ph.color}${st==='todo'?'66':''};">
|
||
<div class="phase-num">Fase ${ph.num}</div>
|
||
<div class="phase-name">${ph.name}</div>
|
||
<div class="phase-status-row">
|
||
<i data-lucide="${ST_ICON[st]}" class="phase-status-icon"></i>
|
||
<span class="phase-pct">${pct}%</span>
|
||
</div>
|
||
<div class="phase-mini-bar">
|
||
<div class="phase-mini-fill" style="width:${pct}%;"></div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
|
||
// ── Phase detail
|
||
html += `<div id="phase-detail-wrap">${renderPhaseDetail(activePhase)}</div>`;
|
||
|
||
document.getElementById('page-body').innerHTML = html;
|
||
drawIcons();
|
||
}
|
||
|
||
function renderPhaseDetail(idx) {
|
||
const ph = PHASES[idx];
|
||
const st = phaseStatus(ph.id);
|
||
|
||
let html = `
|
||
<div class="card card-pad phase-detail-anim">
|
||
<div class="phase-detail-header">
|
||
<span class="phase-detail-badge" style="background:${ph.color};">
|
||
<i data-lucide="${ph.icon}"></i> Fase ${ph.num} — ${ph.name}
|
||
</span>
|
||
<span class="step-badge ${ST_BADGE[st]}">${ST_LBL[st]}</span>
|
||
<span class="phase-detail-desc">${ph.desc}</span>
|
||
</div>
|
||
<div class="step-cards">`;
|
||
|
||
for (const step of ph.steps) {
|
||
const sst = stepStatus(step.mk);
|
||
const met = stepMetric(step.mk);
|
||
html += `
|
||
<a href="${step.href}" class="step-card">
|
||
<div class="step-card-top">
|
||
<div class="step-icon" style="background:${ph.color}18;color:${ph.color};">
|
||
<i data-lucide="${step.icon}"></i>
|
||
</div>
|
||
<span class="step-badge ${ST_BADGE[sst]}">${ST_LBL[sst]}</span>
|
||
</div>
|
||
<h4>${step.name}</h4>
|
||
<p>${step.desc}</p>
|
||
<div class="step-metric">${met.val}</div>
|
||
<div class="step-metric-lbl">${met.lbl}</div>
|
||
<div class="step-cta">Vai al modulo <i data-lucide="arrow-right"></i></div>
|
||
</a>`;
|
||
}
|
||
|
||
html += `</div></div>`;
|
||
return html;
|
||
}
|
||
|
||
function selectPhase(idx) {
|
||
activePhase = idx;
|
||
document.querySelectorAll('.phase-item').forEach((el, i) => {
|
||
el.classList.toggle('active', i === idx);
|
||
});
|
||
document.getElementById('phase-detail-wrap').innerHTML = renderPhaseDetail(idx);
|
||
drawIcons();
|
||
}
|
||
window.selectPhase = selectPhase;
|
||
|
||
// ── Org name in header ────────────────────────────────────────
|
||
try {
|
||
const u = JSON.parse(localStorage.getItem('nis2_user') || '{}');
|
||
if (u.org_name) document.getElementById('org-name-header').textContent = u.org_name;
|
||
} catch(e) {}
|
||
|
||
// ── Start ─────────────────────────────────────────────────────
|
||
loadAll();
|
||
</script>
|
||
</body>
|
||
</html>
|