nis2-agile/public/workflow.html
DevEnv nis2-agile 3e8f24eb49 [FEAT] Compliance Journey — workflow visivo 6 fasi NIS2
- workflow.html: roadmap orizzontale 6 fasi (Preparazione→Valutazione→Rischi→Implementazione→Monitoraggio→Reportistica)
- Dati reali da 9 API in parallelo (assessment, rischi, policy, asset, fornitori, formazione, controlli)
- Auto-selezione fase attiva + dettaglio step con metriche live
- Banner "prossima azione consigliata" contestuale
- Aggiunto link "Compliance Journey" nella sidebar (sezione Principale)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 07:54:15 +01:00

705 lines
32 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Compliance Journey - NIS2 Agile</title>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root {
--nis2-cyan: #06B6D4;
--nis2-cyan-light: #ecfeff;
--nis2-cyan-ring: rgba(6,182,212,.15);
}
/* ── Journey header ────────────────────────────────── */
.journey-header {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
border-radius: var(--border-radius-lg);
padding: 24px 28px;
color: white;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.journey-score {
font-size: 2.8rem;
font-weight: 800;
color: var(--nis2-cyan);
line-height: 1;
}
.journey-label {
font-size: 0.8rem;
opacity: 0.6;
margin-top: 2px;
}
.journey-progress-wrap {
flex: 1;
min-width: 200px;
}
.journey-progress-bar {
height: 10px;
background: rgba(255,255,255,0.12);
border-radius: 5px;
margin-top: 10px;
overflow: hidden;
}
.journey-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--nis2-cyan), #38bdf8);
border-radius: 5px;
transition: width 1s ease;
}
.journey-meta {
text-align: right;
font-size: 0.78rem;
opacity: 0.55;
}
.journey-meta strong { display: block; font-size: 0.9rem; opacity: 1; }
/* ── Next action banner ────────────────────────────── */
.next-action-banner {
background: linear-gradient(135deg, #0c4a6e, #075985);
color: white;
border-radius: var(--border-radius);
padding: 14px 20px;
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 20px;
border-left: 4px solid var(--nis2-cyan);
}
.next-action-banner .na-icon {
font-size: 1.5rem;
color: var(--nis2-cyan);
flex-shrink: 0;
}
.next-action-banner .na-label {
font-size: 0.72rem;
opacity: 0.65;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.next-action-banner .na-text {
font-size: 0.9rem;
font-weight: 600;
}
.next-action-banner a {
color: var(--nis2-cyan);
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: 110px;
position: relative;
cursor: pointer;
}
.phase-box {
padding: 14px 16px 12px 18px;
color: white;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
transition: filter 0.2s, box-shadow 0.2s;
position: relative;
}
.phase-item:first-child .phase-box { border-radius: var(--border-radius) 0 0 var(--border-radius); }
.phase-item:last-child .phase-box { border-radius: 0 var(--border-radius) var(--border-radius) 0; }
/* Arrow connector */
.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 white;
z-index: 2;
}
.phase-item.active .phase-box {
filter: brightness(1.18);
box-shadow: 0 4px 18px rgba(0,0,0,0.25);
z-index: 1;
}
.phase-item:not(.active):hover .phase-box { filter: brightness(1.08); }
.phase-item.todo .phase-box { opacity: 0.6; }
.phase-num { font-size: 0.62rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.7; margin-bottom: 2px; }
.phase-name { font-size: 0.78rem; font-weight: 700; line-height: 1.2; }
.phase-status-row { display: flex; align-items: center; gap: 4px; margin-top: 5px; }
.phase-status-icon { font-size: 0.8rem; }
.phase-pct { font-size: 0.7rem; opacity: 0.85; }
.phase-mini-bar {
height: 3px;
background: rgba(255,255,255,0.2);
border-radius: 2px;
margin-top: 5px;
overflow: hidden;
}
.phase-mini-fill {
height: 100%;
background: rgba(255,255,255,0.75);
border-radius: 2px;
}
/* ── Phase detail ──────────────────────────────────── */
.phase-detail-header {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.phase-detail-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 700;
color: white;
}
.phase-detail-desc {
font-size: 0.82rem;
color: var(--gray-500);
margin-left: auto;
}
/* ── Step cards ────────────────────────────────────── */
.step-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.step-card {
background: var(--card-bg);
border-radius: var(--border-radius);
padding: 16px;
border: 1.5px solid var(--gray-200);
cursor: pointer;
transition: box-shadow var(--transition), border-color var(--transition), transform var(--transition);
text-decoration: none;
display: block;
color: inherit;
}
.step-card:hover {
box-shadow: 0 6px 20px rgba(0,0,0,0.1);
border-color: var(--nis2-cyan);
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: 9px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
.step-badge {
font-size: 0.67rem;
padding: 2px 8px;
border-radius: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-done { background: var(--secondary-bg); color: var(--secondary); }
.badge-partial { background: var(--warning-bg); color: #92400e; }
.badge-todo { background: var(--gray-100); color: var(--gray-500); }
.badge-active { background: var(--primary-bg); color: var(--primary-dark); }
.step-card h4 { font-size: 0.85rem; font-weight: 600; margin-bottom: 3px; color: var(--gray-800); }
.step-card p { font-size: 0.76rem; color: var(--gray-500); line-height: 1.45; margin-bottom: 12px; }
.step-metric { font-size: 1.5rem; font-weight: 800; color: var(--gray-800); line-height: 1; }
.step-metric-lbl { font-size: 0.7rem; color: var(--gray-400); margin-top: 1px; }
.step-cta {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 10px;
font-size: 0.74rem;
font-weight: 600;
color: var(--nis2-cyan);
}
/* ── Legend ────────────────────────────────────────── */
.legend-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 20px;
font-size: 0.78rem;
color: var(--gray-500);
}
.legend-item { display: flex; align-items: center; gap: 5px; }
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.phase-detail-anim { animation: fadeSlideIn 0.2s ease; }
@media (max-width: 640px) {
.phase-name { font-size: 0.7rem; }
.phase-num { display: none; }
.journey-header { flex-direction: column; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2>
<i class="fa-solid fa-route" style="color:var(--nis2-cyan);margin-right:8px;"></i>
Compliance Journey
</h2>
<div class="content-header-actions">
<span class="text-muted" id="org-name-header" style="font-size:0.85rem;"></span>
</div>
</header>
<div class="content-body" id="page-body">
<div class="spinner-lg" style="margin:100px auto;display:block;"></div>
</div>
</main>
</div>
<script src="js/api.js"></script>
<script src="js/common.js"></script>
<script src="js/i18n.js"></script>
<script>
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
// ── Dati globali ────────────────────────────────────────────
let G = {};
let activePhase = 0;
// ── Definizione fasi ────────────────────────────────────────
const PHASES = [
{
id: 'preparazione', num: 1, name: 'Preparazione',
color: '#6366f1', icon: 'fa-rocket',
desc: 'Organizzazione, team e classificazione NIS2',
steps: [
{ id: 'onboarding', name: 'Onboarding NIS2', icon: 'fa-rocket', desc: 'Classificazione settore, dimensione e tipo soggetto NIS2', href: 'onboarding.html', mk: 'onboarding_done' },
{ id: 'org', name: 'Organizzazione', icon: 'fa-building', desc: 'Dati aziendali, P.IVA, classificazione completata', href: 'settings.html', mk: 'org_setup' },
{ id: 'team', name: 'Team & Ruoli', icon: 'fa-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: 'fa-magnifying-glass-chart',
desc: 'Gap analysis, asset e supply chain',
steps: [
{ id: 'assessment', name: 'Gap Assessment', icon: 'fa-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: 'fa-server', desc: 'Censimento asset critici, classificazione e mappa dipendenze', href: 'assets.html', mk: 'assets_count' },
{ id: 'supply', name: 'Supply Chain', icon: 'fa-link', desc: 'Fornitori critici, valutazione rischio e certificazioni', href: 'supply-chain.html', mk: 'suppliers_count' },
]
},
{
id: 'rischi', num: 3, name: 'Rischi',
color: '#dc2626', icon: 'fa-shield-halved',
desc: 'Identificazione, valutazione e trattamento',
steps: [
{ id: 'risks', name: 'Risk Register', icon: 'fa-triangle-exclamation', desc: 'Identificazione e scoring rischi su scala ISO 27005 (5×5)', href: 'risks.html', mk: 'risks_count' },
{ id: 'treatments', name: 'Piano Trattamento', icon: 'fa-shield', desc: 'Azioni di mitigazione, accettazione o trasferimento rischi', href: 'risks.html', mk: 'treatments_pct' },
{ id: 'ai_risk', name: 'AI Risk Suggest', icon: 'fa-wand-magic-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: 'fa-gears',
desc: 'Policy, formazione e controlli di sicurezza',
steps: [
{ id: 'policies', name: 'Policy', icon: 'fa-file-shield', desc: 'Redazione, revisione AI e approvazione policy NIS2', href: 'policies.html', mk: 'policies_approved' },
{ id: 'training', name: 'Formazione', icon: 'fa-graduation-cap', desc: 'Corsi obbligatori assegnati e completati dal personale', href: 'training.html', mk: 'training_pct' },
{ id: 'controls', name: 'Controlli Audit', icon: 'fa-list-check', desc: 'Implementazione controlli ISO 27001 / misure Art.21 NIS2', href: 'reports.html', mk: 'controls_pct' },
]
},
{
id: 'monitoraggio', num: 5, name: 'Monitoraggio',
color: '#d97706', icon: 'fa-eye',
desc: 'Incidenti, non-conformità e normative',
steps: [
{ id: 'incidents', name: 'Incidenti Art.23', icon: 'fa-bell', desc: 'Gestione e notifica CSIRT: 24h early warning, 72h, 30 giorni', href: 'incidents.html', mk: 'incidents_count' },
{ id: 'ncr', name: 'NCR / CAPA', icon: 'fa-circle-xmark', desc: 'Non-conformità rilevate e azioni correttive in corso', href: 'reports.html', mk: 'ncr_open' },
{ id: 'normative', name: 'Aggiornamenti NIS2', icon: 'fa-newspaper', desc: 'Feed ACN, DORA, D.Lgs. 138/2024 con conferma di lettura', href: 'normative.html', mk: 'normative_pending' },
{ id: 'wb', name: 'Segnalazioni', icon: 'fa-user-secret', desc: 'Canale whistleblowing anonimo Art.32 NIS2', href: 'whistleblowing.html', mk: 'wb_open' },
]
},
{
id: 'reportistica', num: 6, name: 'Reportistica',
color: '#7c3aed', icon: 'fa-chart-line',
desc: 'Report esecutivi, audit chain e export',
steps: [
{ id: 'exec_report', name: 'Report Compliance', icon: 'fa-file-chart-line', desc: 'Report esecutivo HTML stampabile come PDF per board e auditor', href: 'reports.html', mk: 'report_gen' },
{ id: 'audit_chain', name: 'Audit Chain', icon: 'fa-link', desc: 'Log immutabile con hash chain SHA-256, verifica integrità', href: 'reports.html', mk: 'audit_records' },
{ id: 'export', name: 'Export Certificato', icon: 'fa-file-export', 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: 'fa-circle-check', partial: 'fa-circle-half-stroke', active: 'fa-circle-dot', todo: 'fa-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: 'fa-clipboard-check' };
if (G.assessment_pct < 100) return { text: 'Completa il Gap Assessment in corso (' + G.assessment_pct + '%)', link: 'assessment.html', icon: 'fa-clipboard-check' };
if (!G.assets_count) return { text: 'Censisci gli asset critici dell\'organizzazione', link: 'assets.html', icon: 'fa-server' };
if (!G.risks_count) return { text: 'Registra i rischi NIS2 nel Risk Register', link: 'risks.html', icon: 'fa-triangle-exclamation' };
if (!G.policies_total) return { text: 'Genera la prima policy con AI', link: 'policies.html', icon: 'fa-file-shield' };
if (G.training_pct < 50) return { text: 'Assegna i corsi di formazione al team', link: 'training.html', icon: 'fa-graduation-cap' };
if (G.controls_pct < 60) return { text: 'Implementa i controlli di sicurezza Art.21', link: 'reports.html', icon: 'fa-list-check' };
return { text: 'Genera il report di compliance per il board', link: 'reports.html', icon: 'fa-chart-line' };
}
// ── 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.55;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.5);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.5;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:2px;">${ph.name}</div>
<div style="font-size:1rem;font-weight:700;color:${st==='done'?'#4ade80':st==='todo'?'rgba(255,255,255,0.3)':'var(--nis2-cyan)'};">${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 class="fa-solid ${next.icon} na-icon"></i>
<div>
<div class="na-label">Prossima azione consigliata</div>
<div class="na-text"><a href="${next.link}">${next.text} &rarr;</a></div>
</div>
</div>`;
// ── Legend
html += `
<div class="legend-row">
<div class="legend-item"><i class="fa-solid fa-circle-check" style="color:var(--secondary);"></i> Completato</div>
<div class="legend-item"><i class="fa-solid fa-circle-half-stroke" style="color:var(--warning);"></i> In corso</div>
<div class="legend-item"><i class="fa-solid fa-circle-dot" style="color:var(--primary);"></i> Attivo / Continuo</div>
<div class="legend-item"><i class="fa-solid fa-circle" style="color:var(--gray-300);"></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 class="fa-solid ${ST_ICON[st]} 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;
}
function renderPhaseDetail(idx) {
const ph = PHASES[idx];
const st = phaseStatus(ph.id);
let html = `
<div class="card phase-detail-anim">
<div class="card-header">
<div class="phase-detail-header">
<span class="phase-detail-badge" style="background:${ph.color};">
<i class="fa-solid ${ph.icon}"></i> Fase ${ph.num} &mdash; ${ph.name}
</span>
<span class="step-badge ${ST_BADGE[st]}">${ST_LBL[st]}</span>
<span class="phase-detail-desc">${ph.desc}</span>
</div>
</div>
<div class="card-body">
<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 class="fa-solid ${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 class="fa-solid fa-arrow-right" style="font-size:0.65rem;margin-left:2px;"></i></div>
</a>`;
}
html += `</div></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);
}
// ── 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>