Riscontro Simon sulla tabella Gestione Rischi: - Titoli di gruppo sui tripletti Probabilità/Impatto/Valutazione: da "VALUTAZIONE RISCHIO INERENTE/RESIDUO" a "Rischio Inerente"/"Rischio Residuo" (risks.html thead + i18n.js grp_inherent/grp_residual IT+EN). - Ritirata eccezione #3: banda Alert riportata a spec Simon 15-25/9-12/3-8/1-2. Simon ha ragione: con P,I in 1-5 i prodotti 7/11/13/14 non esistono → nessun buco. alertBand resta >=9 (=9-12 in pratica); aggiornati commento, help.js, manuale, KB (9-14 -> 9-12). - Cache-buster help.js/i18n.js -> 20260730b (i18n va bustato o riscrive il titolo). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
867 lines
44 KiB
HTML
867 lines
44 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>GAP Analysis · NIS2 Agile</title>
|
|
<!-- UI V3: Inter self-host + design system v3.css. Self-host only: no external font/script hosts. -->
|
|
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
|
|
<link rel="stylesheet" href="/css/v3.css?v=20260627">
|
|
<!-- 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 -->
|
|
<style>
|
|
/* ── GAP Analysis V3 — solo widget NON coperti da v3.css (token condivisi). ──
|
|
Fonte visiva: docs/mockup Ui/assessment.html (.ga-intro, .stat KPI, .det/.crow,
|
|
.gbadge). Le viste reali (wizard domande, gauge, categorie) sono renderizzate
|
|
dal JS della pagina → qui ne forniamo lo stile minimo (no CDN, no Bootstrap). */
|
|
.ga-intro{display:flex;gap:12px;align-items:flex-start;background:#EEF4FB;border:1px solid #D5E3F2;color:#2C4A6B;border-radius:11px;padding:14px 16px;font-size:13px;margin-bottom:20px}
|
|
.ga-intro .ic{width:18px;height:18px;flex:0 0 auto;color:var(--c-blue);margin-top:1px}
|
|
.ga-kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:20px}
|
|
.center-card{text-align:center;padding:40px 24px}
|
|
.center-card h3{font-size:18px;font-weight:800;margin:0 0 8px}
|
|
.center-card p{color:var(--muted);margin:0 auto 22px;max-width:560px;font-size:13.5px}
|
|
.btn-lg{padding:12px 20px;font-size:14px;border-radius:10px}
|
|
|
|
/* tabella assessment esistenti */
|
|
.v3-table{width:100%;border-collapse:collapse;font-size:13px}
|
|
.v3-table th{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#8093A8;text-align:left;padding:9px 12px;border-bottom:1px solid var(--line)}
|
|
.v3-table td{padding:11px 12px;border-top:1px solid var(--line)}
|
|
.v3-table tr:first-child td{border-top:0}
|
|
|
|
/* wizard: indicatore step */
|
|
.steps{display:flex;align-items:center;flex-wrap:wrap;gap:4px 0;margin-bottom:18px}
|
|
.step{display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:9px;cursor:pointer;color:var(--muted)}
|
|
.step:hover{background:#F3F7FC}
|
|
.step .step-number{width:24px;height:24px;border-radius:50%;background:#E7ECF2;color:#5b6b80;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex:0 0 auto}
|
|
.step .step-label{font-size:12.5px;font-weight:600;white-space:nowrap}
|
|
.step.active{color:var(--ink)}
|
|
.step.active .step-number{background:var(--c-blue);color:#fff}
|
|
.step.completed{color:var(--c-green)}
|
|
.step.completed .step-number{background:var(--c-green);color:#fff}
|
|
.step-connector{flex:1;min-width:14px;height:2px;background:var(--line);margin:0 2px}
|
|
|
|
/* progress bar avanzamento risposte */
|
|
.ga-prog-wrap{margin-bottom:16px}
|
|
.ga-prog-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
|
.ga-prog-lab{font-size:11.5px;font-weight:700;color:#7a8aa0;text-transform:uppercase;letter-spacing:.04em}
|
|
.ga-prog-pct{font-size:12.5px;font-weight:800;color:var(--c-blue)}
|
|
.ga-prog-track{background:#EAF0F6;border-radius:99px;height:8px;overflow:hidden}
|
|
.ga-prog-fill{height:100%;background:var(--c-blue);border-radius:99px;transition:width .3s ease;width:0}
|
|
.ga-prog-sub{font-size:11px;color:var(--muted);margin-top:4px}
|
|
|
|
/* card domanda */
|
|
.qcard-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid var(--line)}
|
|
.qcard-head h3{margin:0;font-size:15px;font-weight:700}
|
|
.qcard-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:14px 20px;border-top:1px solid var(--line);flex-wrap:wrap}
|
|
|
|
/* domanda: testo + tag */
|
|
.q-text{font-size:15px;font-weight:600;color:var(--ink);margin:0 0 4px}
|
|
.q-guidance{color:var(--muted);font-size:13px;margin:0 0 8px}
|
|
.q-tag{display:inline-block;font-size:10.5px;font-weight:700;padding:3px 8px;border-radius:6px;background:#EEF2F7;color:#5b6b80;margin-top:6px;margin-right:4px}
|
|
|
|
/* form */
|
|
.form-group{margin-bottom:18px}
|
|
.form-label{display:block;font-size:11.5px;font-weight:700;color:#7a8aa0;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px}
|
|
.form-radio-group{display:flex;flex-wrap:wrap;gap:9px}
|
|
.form-radio{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:9px;padding:9px 13px;font-size:13px;font-weight:600;cursor:pointer;background:#fff}
|
|
.form-radio:hover{border-color:#9FBEDD}
|
|
.form-radio.selected{border-color:var(--c-blue);background:#EEF4FB;box-shadow:0 0 0 2px rgba(31,95,168,.15)}
|
|
.form-radio input{accent-color:var(--c-blue);cursor:pointer}
|
|
.form-range{width:100%;accent-color:var(--c-blue);cursor:pointer}
|
|
.form-range-labels{display:flex;justify-content:space-between;align-items:center;font-size:11.5px;color:var(--muted);margin-top:6px}
|
|
.form-range-labels #maturity-value{font-size:14px;font-weight:800;color:var(--c-blue)}
|
|
.form-textarea,.form-input{width:100%;border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit;font-size:13.5px;color:var(--ink);background:#fff;outline:none}
|
|
.form-textarea:focus,.form-input:focus{border-color:var(--c-blue);box-shadow:0 0 0 3px rgba(31,95,168,.12)}
|
|
.form-textarea{resize:vertical;min-height:70px}
|
|
|
|
/* badge generici (renderizzati dal JS) */
|
|
.badge{display:inline-block;font-size:10.5px;font-weight:700;padding:3px 9px;border-radius:999px;white-space:nowrap}
|
|
.badge-neutral{background:#EEF2F7;color:#5b6b80}
|
|
.badge-success{background:#E6F4EC;color:#1E7A4E}
|
|
.badge-warning{background:#FBF1E2;color:#9A6418}
|
|
.badge-info{background:#E7F0FB;color:#1F5FA8}
|
|
|
|
/* bottoni stato (JS riusa .btn-success per "Completa") */
|
|
.btn-success{display:inline-flex;align-items:center;gap:7px;background:var(--c-green);color:#fff;border:0;border-radius:8px;padding:8px 13px;font:inherit;font-weight:600;font-size:12.5px;cursor:pointer}
|
|
.btn-success:hover{background:#19764C}
|
|
.btn-secondary{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 13px;font:inherit;font-weight:600;font-size:12.5px;color:var(--ink);cursor:pointer}
|
|
.btn-secondary:hover{background:#F5F8FC}
|
|
.btn-secondary:disabled,.btn-primary:disabled,.btn-success:disabled{opacity:.5;cursor:not-allowed}
|
|
.btn-sm{padding:6px 11px;font-size:12px}
|
|
.btn-outline{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--c-blue);background:#fff;border-radius:8px;padding:7px 12px;font:inherit;font-weight:600;font-size:12.5px;color:var(--c-blue);cursor:pointer}
|
|
.btn-outline:hover{background:#EEF4FB}
|
|
|
|
/* risultati: gauge (renderizzato da common.renderScoreGauge) */
|
|
.score-gauge{position:relative;width:200px;height:200px;margin:0 auto}
|
|
.score-gauge svg{transform:rotate(-90deg)}
|
|
.score-gauge-circle-bg{fill:none;stroke:#EAF0F6;stroke-width:12}
|
|
.score-gauge-circle{fill:none;stroke-width:12;stroke-linecap:round;stroke:var(--score-color,var(--c-blue));transition:stroke-dashoffset 1s ease,stroke .5s ease}
|
|
.score-gauge-value{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center}
|
|
.score-gauge-number{font-size:2.6rem;font-weight:800;color:var(--ink);line-height:1}
|
|
.score-gauge-label{font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-top:4px}
|
|
.score-critical{--score-color:var(--c-red)}.score-low{--score-color:#f97316}.score-medium{--score-color:var(--c-amber)}.score-good{--score-color:#84cc16}.score-excellent{--score-color:var(--c-green)}
|
|
|
|
/* risultati: punteggio per categoria */
|
|
.category-score{margin-bottom:14px}
|
|
.category-score:last-child{margin-bottom:0}
|
|
.category-score-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
|
.category-score-name{font-size:13px;font-weight:600;color:var(--ink)}
|
|
.category-score-value{font-size:13px;font-weight:800}
|
|
.progress{background:#EAF0F6;border-radius:99px;height:8px;overflow:hidden}
|
|
.progress-bar{height:100%;border-radius:99px;background:var(--c-blue);transition:width .4s ease}
|
|
.progress-bar.success{background:var(--c-green)}.progress-bar.warning{background:var(--c-amber)}.progress-bar.danger{background:var(--c-red)}
|
|
|
|
/* spinner inline (riusato dal JS) */
|
|
.spinner-lg{width:36px;height:36px;border:3px solid var(--line);border-top-color:var(--c-blue);border-radius:50%;animation:v3spin .7s linear infinite}
|
|
.spinner{width:16px;height:16px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;animation:v3spin .7s linear infinite;display:inline-block;vertical-align:middle}
|
|
|
|
.mb-24{margin-bottom:24px}
|
|
.text-muted{color:var(--muted)}
|
|
.ai-block h4{margin:0 0 8px;font-size:14px;font-weight:700}
|
|
.ai-block ul,.ai-block ol{margin:0;padding-left:20px;color:var(--muted)}
|
|
@media (max-width:900px){.ga-kpis{grid-template-columns:1fr}}
|
|
@media (prefers-reduced-motion:reduce){.spinner-lg,.spinner{animation:none}}
|
|
</style>
|
|
</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 data-i18n="assessment.title">Gap Analysis NIS2</span>
|
|
</nav>
|
|
<div class="page-head">
|
|
<div>
|
|
<h1 data-i18n="assessment.title">Gap Analysis NIS2</h1>
|
|
<p class="sub">Scostamento tra lo stato attuale e i requisiti della Direttiva (UE) 2022/2555</p>
|
|
</div>
|
|
<div class="content-header-actions">
|
|
<button class="btn-outline" id="btn-ai-analyze" style="display:none;" onclick="aiAnalyze()">
|
|
<i data-lucide="sparkles" class="ic"></i> Analisi AI
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ga-intro">
|
|
<i data-lucide="info" class="ic"></i>
|
|
<div>L'assessment confronta il livello di conformità <b>effettivo</b> della tua organizzazione con quello <b>atteso</b> dai requisiti NIS2, evidenziando i gap su cui intervenire. I gap rilevati possono essere convertiti automaticamente in non conformità (NCR).</div>
|
|
</div>
|
|
|
|
<!-- KPI sintetici (dati reali, popolati dal JS) -->
|
|
<div class="ga-kpis" id="ga-kpis" style="display:none;"></div>
|
|
|
|
<!-- Assessment Selection / Create -->
|
|
<section class="card mb-24" id="assessment-start">
|
|
<div class="center-card">
|
|
<h3>Assessment di Conformità NIS2</h3>
|
|
<p>Valuta il livello di conformità della tua organizzazione rispetto ai requisiti della direttiva NIS2.</p>
|
|
|
|
<div id="existing-assessments" class="mb-24" style="display:none; text-align:left;"></div>
|
|
|
|
<button class="btn-primary btn-lg" id="btn-new-assessment" onclick="createNewAssessment()">
|
|
<i data-lucide="plus" class="ic"></i> Nuovo Assessment
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Wizard Container -->
|
|
<div id="assessment-wizard" style="display:none;">
|
|
<!-- Steps Indicator -->
|
|
<div class="steps" id="steps-indicator"></div>
|
|
|
|
<!-- Progress Bar -->
|
|
<div class="ga-prog-wrap">
|
|
<div class="ga-prog-top">
|
|
<span class="ga-prog-lab">Avanzamento risposta domande</span>
|
|
<span class="ga-prog-pct" id="progress-pct">0%</span>
|
|
</div>
|
|
<div class="ga-prog-track">
|
|
<div id="progress-bar-fill" class="ga-prog-fill"></div>
|
|
</div>
|
|
<div class="ga-prog-sub">
|
|
<span id="progress-answered">0</span> di <span id="progress-total">0</span> domande con risposta
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Question Card -->
|
|
<section class="card mb-24">
|
|
<div class="qcard-head">
|
|
<h3 id="category-title">Caricamento...</h3>
|
|
<span class="badge badge-neutral" id="question-counter">0/0</span>
|
|
</div>
|
|
<div class="card-pad" id="question-area">
|
|
<div class="spinner-lg" style="margin:40px auto;"></div>
|
|
</div>
|
|
<div class="qcard-foot">
|
|
<button class="btn-secondary" id="btn-prev" onclick="prevQuestion()" disabled>
|
|
<i data-lucide="chevron-left" class="ic"></i> Precedente
|
|
</button>
|
|
<button class="btn-ghost btn-sm" onclick="saveCurrentResponse()">
|
|
Salva Progresso
|
|
</button>
|
|
<button class="btn-primary" id="btn-next" onclick="nextQuestion()">
|
|
Successiva <i data-lucide="chevron-right" class="ic"></i>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Results Container -->
|
|
<div id="assessment-results" style="display:none;">
|
|
<section class="card mb-24">
|
|
<div class="qcard-head">
|
|
<h3>Risultati Assessment</h3>
|
|
<button class="btn-outline btn-sm" onclick="aiAnalyze()">
|
|
<i data-lucide="sparkles" class="ic"></i> Analisi AI
|
|
</button>
|
|
</div>
|
|
<div class="card-pad">
|
|
<!-- Overall Score -->
|
|
<div style="text-align:center;" class="mb-24" id="results-gauge"></div>
|
|
|
|
<!-- Category Scores -->
|
|
<h4 style="margin:0 0 16px; font-size:15px; font-weight:700;">Punteggio per Categoria</h4>
|
|
<div id="category-scores"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- AI Analysis result -->
|
|
<section class="card mb-24" id="ai-analysis-card" style="display:none;">
|
|
<div class="qcard-head">
|
|
<h3>Analisi AI</h3>
|
|
<span class="badge badge-info">Intelligenza Artificiale</span>
|
|
</div>
|
|
<div class="card-pad" id="ai-analysis-content"></div>
|
|
</section>
|
|
|
|
<!-- Generate NCR from gaps -->
|
|
<section class="card mb-24" id="ncr-generation-card">
|
|
<div class="qcard-head">
|
|
<h3>Non Conformità</h3>
|
|
</div>
|
|
<div class="center-card">
|
|
<p>
|
|
Genera automaticamente le non conformità (NCR) dai gap identificati nell'assessment.
|
|
I controlli con stato "Non Implementato" saranno classificati come <strong>major</strong>,
|
|
quelli "Parzialmente Implementato" come <strong>minor</strong>.
|
|
</p>
|
|
<button class="btn-primary" id="btn-generate-ncr" onclick="generateNCRs()">
|
|
<i data-lucide="file-plus" class="ic"></i> Genera Non Conformità dai Gap
|
|
</button>
|
|
<div id="ncr-generation-result" style="display:none; margin-top:16px; text-align:left;"></div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
|
|
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help. La logica
|
|
pagina (wizard GAP) resta inline: usa gli stessi id/funzioni di prima. -->
|
|
<script src="/js/api.js?v=20260730a"></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=20260730b"></script>
|
|
<script src="/js/help.js?v=20260730b"></script>
|
|
<script>
|
|
// ── Auth check ───────────────────────────────────────────
|
|
if (!checkAuth()) throw new Error('Not authenticated');
|
|
|
|
// ── Init chrome + i18n + help ────────────────────────────
|
|
loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
|
|
if (typeof I18n !== 'undefined') I18n.init();
|
|
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
|
|
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
|
|
|
|
function refreshIcons() { if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} } }
|
|
|
|
// ── State ────────────────────────────────────────────────
|
|
let currentAssessmentId = null;
|
|
let questions = [];
|
|
let categories = [];
|
|
let currentCategoryIdx = 0;
|
|
let currentQuestionIdx = 0;
|
|
let responses = {}; // questionId -> { answer, maturity, notes, evidence }
|
|
|
|
// ── Init (dati reali) ────────────────────────────────────
|
|
loadAssessments();
|
|
|
|
async function loadAssessments() {
|
|
try {
|
|
const result = await api.listAssessments();
|
|
if (result.success && result.data && result.data.length > 0) {
|
|
const container = document.getElementById('existing-assessments');
|
|
container.style.display = 'block';
|
|
|
|
let html = '<h4 style="margin:0 0 12px; font-size:13px; font-weight:700; color:#7a8aa0; text-transform:uppercase; letter-spacing:.04em;">Assessment Esistenti</h4>';
|
|
html += '<div class="card card-pad" style="padding:6px 14px;"><table class="v3-table"><thead><tr><th>Data</th><th>Stato</th><th>Punteggio</th><th>Azioni</th></tr></thead><tbody>';
|
|
|
|
result.data.forEach(a => {
|
|
const statusBadge = a.status === 'completed'
|
|
? '<span class="badge badge-success">Completato</span>'
|
|
: '<span class="badge badge-warning">In Corso</span>';
|
|
html += `
|
|
<tr>
|
|
<td>${formatDate(a.created_at)}</td>
|
|
<td>${statusBadge}</td>
|
|
<td>${a.overall_score != null ? Math.round(a.overall_score) + '%' : '—'}</td>
|
|
<td>
|
|
${a.status === 'completed'
|
|
? `<button class="btn-ghost btn-sm" onclick="viewResults('${a.id}')">Risultati</button>`
|
|
: `<button class="btn-primary btn-sm" onclick="resumeAssessment('${a.id}')">Riprendi</button>`
|
|
}
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
|
|
html += '</tbody></table></div>';
|
|
container.innerHTML = html;
|
|
refreshIcons();
|
|
|
|
// KPI sintetici (dati reali)
|
|
renderKpis(result.data);
|
|
}
|
|
} catch (e) {
|
|
// Silenzioso
|
|
}
|
|
}
|
|
|
|
// ── KPI sintetici dai dati reali assessment ──────────────
|
|
function renderKpis(list) {
|
|
const wrap = document.getElementById('ga-kpis');
|
|
if (!wrap || !Array.isArray(list) || !list.length) return;
|
|
const total = list.length;
|
|
const completed = list.filter(a => a.status === 'completed').length;
|
|
const inProgress = total - completed;
|
|
const scored = list.filter(a => a.overall_score != null);
|
|
const avg = scored.length ? Math.round(scored.reduce((s, a) => s + Number(a.overall_score || 0), 0) / scored.length) : null;
|
|
const cards = [
|
|
['blue', 'clipboard-list', 'Assessment', total, 'totali'],
|
|
['amber', 'loader', 'In corso', inProgress, completed + ' completati'],
|
|
['green', 'target', 'Conformità media', avg != null ? (avg + '%') : '—', scored.length ? ('su ' + scored.length + ' completati') : 'nessun punteggio']
|
|
];
|
|
wrap.innerHTML = cards.map(c =>
|
|
'<div class="card card-pad stat ' + c[0] + '"><div class="ibox"><i data-lucide="' + c[1] + '" class="ic"></i></div>' +
|
|
'<div style="flex:1;min-width:0"><div class="lbl">' + c[2] + '</div><div class="val">' + c[3] + '</div><div class="sub">' + c[4] + '</div></div></div>'
|
|
).join('');
|
|
wrap.style.display = 'grid';
|
|
refreshIcons();
|
|
}
|
|
|
|
async function createNewAssessment() {
|
|
const btn = document.getElementById('btn-new-assessment');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Creazione in corso...';
|
|
|
|
try {
|
|
const result = await api.createAssessment({ title: 'Assessment NIS2 - ' + formatDate(new Date()) });
|
|
if (result.success && result.data) {
|
|
currentAssessmentId = result.data.id;
|
|
await loadQuestions();
|
|
} else {
|
|
showNotification(result.message || 'Errore nella creazione.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = `<i data-lucide="plus" class="ic"></i> Nuovo Assessment`;
|
|
refreshIcons();
|
|
}
|
|
}
|
|
|
|
async function resumeAssessment(id) {
|
|
currentAssessmentId = id;
|
|
await loadQuestions();
|
|
}
|
|
|
|
async function viewResults(id) {
|
|
currentAssessmentId = id;
|
|
await showResults();
|
|
}
|
|
|
|
async function loadQuestions() {
|
|
try {
|
|
const result = await api.getAssessmentQuestions(currentAssessmentId);
|
|
if (result.success && result.data) {
|
|
// API returns array of {category_id, category_title, questions: [...]}
|
|
const data = result.data;
|
|
if (Array.isArray(data) && data.length > 0 && data[0].questions) {
|
|
questions = [];
|
|
data.forEach(cat => {
|
|
(cat.questions || []).forEach(q => questions.push(q));
|
|
});
|
|
} else {
|
|
questions = data;
|
|
}
|
|
organizeByCategory();
|
|
showWizard();
|
|
renderCurrentQuestion();
|
|
} else {
|
|
showNotification('Errore nel caricamento delle domande.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
}
|
|
|
|
function organizeByCategory() {
|
|
const catMap = {};
|
|
questions.forEach(q => {
|
|
const cat = q.category || 'Generale';
|
|
if (!catMap[cat]) catMap[cat] = [];
|
|
catMap[cat].push(q);
|
|
});
|
|
categories = Object.keys(catMap).map(name => ({
|
|
name,
|
|
questions: catMap[name]
|
|
}));
|
|
|
|
// Ripristina risposte precedenti (backend puts response_value directly on question)
|
|
questions.forEach(q => {
|
|
if (q.response_value) {
|
|
responses[q.id] = {
|
|
answer: q.response_value,
|
|
maturity: q.maturity_level ? parseInt(q.maturity_level) : 0,
|
|
notes: q.notes || '',
|
|
evidence: q.evidence_description || ''
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
function showWizard() {
|
|
document.getElementById('assessment-start').style.display = 'none';
|
|
document.getElementById('assessment-wizard').style.display = 'block';
|
|
document.getElementById('assessment-results').style.display = 'none';
|
|
renderSteps();
|
|
}
|
|
|
|
function renderSteps() {
|
|
const container = document.getElementById('steps-indicator');
|
|
let html = '';
|
|
categories.forEach((cat, i) => {
|
|
const cls = i === currentCategoryIdx ? 'active' : i < currentCategoryIdx ? 'completed' : '';
|
|
html += `<div class="step ${cls}" onclick="goToCategory(${i})">
|
|
<span class="step-number">${i + 1}</span>
|
|
<span class="step-label">${escapeHtml(cat.name)}</span>
|
|
</div>`;
|
|
if (i < categories.length - 1) {
|
|
html += `<div class="step-connector"></div>`;
|
|
}
|
|
});
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function goToCategory(idx) {
|
|
if (idx <= currentCategoryIdx || idx === currentCategoryIdx + 1) {
|
|
saveCurrentResponseSilent();
|
|
currentCategoryIdx = idx;
|
|
currentQuestionIdx = 0;
|
|
renderSteps();
|
|
renderCurrentQuestion();
|
|
}
|
|
}
|
|
|
|
function updateProgressBar() {
|
|
const total = questions.length;
|
|
const answered = Object.values(responses).filter(r => r && r.answer).length;
|
|
const pct = total > 0 ? Math.round((answered / total) * 100) : 0;
|
|
|
|
const pctEl = document.getElementById('progress-pct');
|
|
const fillEl = document.getElementById('progress-bar-fill');
|
|
const answeredEl = document.getElementById('progress-answered');
|
|
const totalEl = document.getElementById('progress-total');
|
|
|
|
if (pctEl) pctEl.textContent = pct + '%';
|
|
if (fillEl) fillEl.style.width = pct + '%';
|
|
if (answeredEl) answeredEl.textContent = answered;
|
|
if (totalEl) totalEl.textContent = total;
|
|
}
|
|
|
|
function renderCurrentQuestion() {
|
|
if (categories.length === 0) return;
|
|
|
|
const cat = categories[currentCategoryIdx];
|
|
const q = cat.questions[currentQuestionIdx];
|
|
const globalIdx = getGlobalQuestionIndex();
|
|
const totalQuestions = questions.length;
|
|
|
|
document.getElementById('category-title').textContent = cat.name;
|
|
document.getElementById('question-counter').textContent = `${globalIdx + 1}/${totalQuestions}`;
|
|
|
|
const r = responses[q.id] || {};
|
|
|
|
const answers = [
|
|
{ value: 'not_implemented', label: 'Non Implementato', cls: 'danger' },
|
|
{ value: 'partial', label: 'Parziale', cls: 'warning' },
|
|
{ value: 'implemented', label: 'Implementato', cls: 'success' },
|
|
{ value: 'not_applicable', label: 'Non Applicabile', cls: 'neutral' },
|
|
];
|
|
|
|
let html = `
|
|
<div style="margin-bottom:20px;">
|
|
<p class="q-text">${escapeHtml(q.question_text || q.text || q.title || '')}</p>
|
|
${q.guidance_it ? `<p class="q-guidance">${escapeHtml(q.guidance_it)}</p>` : ''}
|
|
${q.nis2_article ? `<span class="q-tag">Art. ${escapeHtml(q.nis2_article)}</span>` : ''}
|
|
${q.iso27001_control ? `<span class="q-tag">ISO ${escapeHtml(q.iso27001_control)}</span>` : ''}
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Livello di Conformità</label>
|
|
<div class="form-radio-group">
|
|
${answers.map(a => `
|
|
<label class="form-radio ${r.answer === a.value ? 'selected' : ''}">
|
|
<input type="radio" name="answer" value="${a.value}" ${r.answer === a.value ? 'checked' : ''}
|
|
onchange="onAnswerChange(this)">
|
|
${a.label}
|
|
</label>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Livello di Maturità (0-5)</label>
|
|
<input type="range" class="form-range" min="0" max="5" step="1"
|
|
value="${r.maturity != null ? r.maturity : 0}" id="maturity-slider"
|
|
oninput="document.getElementById('maturity-value').textContent = this.value">
|
|
<div class="form-range-labels">
|
|
<span>0 - Inesistente</span>
|
|
<span id="maturity-value">${r.maturity != null ? r.maturity : 0}</span>
|
|
<span>5 - Ottimizzato</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Note</label>
|
|
<textarea class="form-textarea" id="question-notes" rows="3"
|
|
placeholder="Aggiungi eventuali note o osservazioni...">${escapeHtml(r.notes || '')}</textarea>
|
|
</div>
|
|
|
|
<div class="form-group" style="margin-bottom:0;">
|
|
<label class="form-label">Descrizione Evidenza</label>
|
|
<input type="text" class="form-input" id="question-evidence"
|
|
placeholder="Documenti, procedure, strumenti a supporto..."
|
|
value="${escapeHtml(r.evidence || '')}">
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('question-area').innerHTML = html;
|
|
updateProgressBar();
|
|
|
|
// Navigation buttons
|
|
document.getElementById('btn-prev').disabled = (currentCategoryIdx === 0 && currentQuestionIdx === 0);
|
|
|
|
const isLast = currentCategoryIdx === categories.length - 1 && currentQuestionIdx === cat.questions.length - 1;
|
|
const nextBtn = document.getElementById('btn-next');
|
|
if (isLast) {
|
|
nextBtn.innerHTML = '<i data-lucide="check" class="ic"></i> Completa Assessment';
|
|
nextBtn.className = 'btn-success';
|
|
} else {
|
|
nextBtn.innerHTML = 'Successiva <i data-lucide="chevron-right" class="ic"></i>';
|
|
nextBtn.className = 'btn-primary';
|
|
}
|
|
refreshIcons();
|
|
}
|
|
|
|
function onAnswerChange(radio) {
|
|
// Highlight selected radio
|
|
document.querySelectorAll('.form-radio').forEach(el => el.classList.remove('selected'));
|
|
radio.closest('.form-radio').classList.add('selected');
|
|
}
|
|
|
|
function getGlobalQuestionIndex() {
|
|
let idx = 0;
|
|
for (let i = 0; i < currentCategoryIdx; i++) {
|
|
idx += categories[i].questions.length;
|
|
}
|
|
return idx + currentQuestionIdx;
|
|
}
|
|
|
|
function saveCurrentResponseSilent() {
|
|
if (categories.length === 0) return;
|
|
|
|
const cat = categories[currentCategoryIdx];
|
|
const q = cat.questions[currentQuestionIdx];
|
|
const selectedRadio = document.querySelector('input[name="answer"]:checked');
|
|
const maturity = document.getElementById('maturity-slider');
|
|
const notes = document.getElementById('question-notes');
|
|
const evidence = document.getElementById('question-evidence');
|
|
|
|
responses[q.id] = {
|
|
answer: selectedRadio ? selectedRadio.value : null,
|
|
maturity: maturity ? parseInt(maturity.value) : 0,
|
|
notes: notes ? notes.value : '',
|
|
evidence: evidence ? evidence.value : ''
|
|
};
|
|
}
|
|
|
|
async function saveCurrentResponse() {
|
|
saveCurrentResponseSilent();
|
|
const cat = categories[currentCategoryIdx];
|
|
const q = cat.questions[currentQuestionIdx];
|
|
const r = responses[q.id];
|
|
|
|
if (!r || !r.answer) {
|
|
showNotification('Seleziona un livello di conformità prima di salvare.', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await api.saveAssessmentResponse(currentAssessmentId, {
|
|
question_code: q.question_code,
|
|
response_value: r.answer,
|
|
maturity_level: r.maturity,
|
|
notes: r.notes,
|
|
evidence_description: r.evidence
|
|
});
|
|
|
|
if (result.success) {
|
|
showNotification('Risposta salvata.', 'success', 2000);
|
|
} else {
|
|
showNotification(result.message || 'Errore nel salvataggio.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
}
|
|
|
|
async function nextQuestion() {
|
|
saveCurrentResponseSilent();
|
|
|
|
// Salva la risposta corrente
|
|
const cat = categories[currentCategoryIdx];
|
|
const q = cat.questions[currentQuestionIdx];
|
|
const r = responses[q.id];
|
|
|
|
if (r && r.answer) {
|
|
// Salva in background
|
|
api.saveAssessmentResponse(currentAssessmentId, {
|
|
question_code: q.question_code,
|
|
response_value: r.answer,
|
|
maturity_level: r.maturity,
|
|
notes: r.notes,
|
|
evidence_description: r.evidence
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// Naviga avanti
|
|
const isLastInCategory = currentQuestionIdx >= cat.questions.length - 1;
|
|
const isLastCategory = currentCategoryIdx >= categories.length - 1;
|
|
|
|
if (isLastInCategory && isLastCategory) {
|
|
// Completa l'assessment
|
|
await completeAssessment();
|
|
} else if (isLastInCategory) {
|
|
currentCategoryIdx++;
|
|
currentQuestionIdx = 0;
|
|
renderSteps();
|
|
renderCurrentQuestion();
|
|
} else {
|
|
currentQuestionIdx++;
|
|
renderCurrentQuestion();
|
|
}
|
|
}
|
|
|
|
function prevQuestion() {
|
|
saveCurrentResponseSilent();
|
|
|
|
if (currentQuestionIdx > 0) {
|
|
currentQuestionIdx--;
|
|
} else if (currentCategoryIdx > 0) {
|
|
currentCategoryIdx--;
|
|
currentQuestionIdx = categories[currentCategoryIdx].questions.length - 1;
|
|
}
|
|
|
|
renderSteps();
|
|
renderCurrentQuestion();
|
|
}
|
|
|
|
async function completeAssessment() {
|
|
try {
|
|
const result = await api.completeAssessment(currentAssessmentId);
|
|
if (result.success) {
|
|
showNotification('Assessment completato!', 'success');
|
|
await showResults();
|
|
} else {
|
|
showNotification(result.message || 'Errore nel completamento.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
}
|
|
|
|
async function showResults() {
|
|
document.getElementById('assessment-start').style.display = 'none';
|
|
document.getElementById('assessment-wizard').style.display = 'none';
|
|
document.getElementById('assessment-results').style.display = 'block';
|
|
document.getElementById('btn-ai-analyze').style.display = '';
|
|
refreshIcons();
|
|
|
|
try {
|
|
const result = await api.getAssessmentReport(currentAssessmentId);
|
|
if (result.success && result.data) {
|
|
const report = result.data;
|
|
|
|
// Overall score gauge
|
|
const score = report.overall_score || 0;
|
|
document.getElementById('results-gauge').innerHTML = renderScoreGauge(score, 200);
|
|
|
|
// Category scores
|
|
const catScores = report.category_scores || report.categories || {};
|
|
let scoresHtml = '';
|
|
|
|
const entries = Array.isArray(catScores) ? catScores : Object.entries(catScores).map(([k, v]) => ({
|
|
name: k,
|
|
score: typeof v === 'object' ? v.score : v
|
|
}));
|
|
|
|
entries.forEach(cat => {
|
|
const catName = cat.name || cat.category;
|
|
const catScore = Math.round(cat.score || 0);
|
|
const color = getScoreColor(catScore);
|
|
const barClass = catScore >= 60 ? 'success' : catScore >= 40 ? 'warning' : 'danger';
|
|
|
|
scoresHtml += `
|
|
<div class="category-score">
|
|
<div class="category-score-header">
|
|
<span class="category-score-name">${escapeHtml(catName)}</span>
|
|
<span class="category-score-value" style="color:${color}">${catScore}%</span>
|
|
</div>
|
|
<div class="progress">
|
|
<div class="progress-bar ${barClass}" style="width:${catScore}%"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
document.getElementById('category-scores').innerHTML = scoresHtml || '<p class="text-muted">Nessun dato disponibile per le categorie.</p>';
|
|
} else {
|
|
document.getElementById('results-gauge').innerHTML = renderScoreGauge(0, 200);
|
|
document.getElementById('category-scores').innerHTML = '<p class="text-muted">Report non ancora disponibile.</p>';
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore nel caricamento dei risultati.', 'error');
|
|
}
|
|
}
|
|
|
|
async function aiAnalyze() {
|
|
if (!currentAssessmentId) {
|
|
showNotification('Nessun assessment selezionato.', 'warning');
|
|
return;
|
|
}
|
|
|
|
showNotification('Analisi AI in corso... Potrebbe richiedere qualche secondo.', 'info', 6000);
|
|
|
|
try {
|
|
const result = await api.aiAnalyzeAssessment(currentAssessmentId);
|
|
if (result.success && result.data) {
|
|
const card = document.getElementById('ai-analysis-card');
|
|
const content = document.getElementById('ai-analysis-content');
|
|
card.style.display = 'block';
|
|
|
|
const analysis = result.data;
|
|
let html = '';
|
|
|
|
if (analysis.summary) {
|
|
html += `<div class="ai-block" style="margin-bottom:20px;"><h4>Riepilogo</h4><p style="color:var(--muted); line-height:1.7;">${escapeHtml(analysis.summary)}</p></div>`;
|
|
}
|
|
|
|
if (analysis.strengths && analysis.strengths.length > 0) {
|
|
html += `<div class="ai-block" style="margin-bottom:20px;"><h4 style="color:var(--c-green);">Punti di Forza</h4><ul>`;
|
|
analysis.strengths.forEach(s => { html += `<li style="margin-bottom:4px;">${escapeHtml(s)}</li>`; });
|
|
html += '</ul></div>';
|
|
}
|
|
|
|
if (analysis.weaknesses && analysis.weaknesses.length > 0) {
|
|
html += `<div class="ai-block" style="margin-bottom:20px;"><h4 style="color:var(--c-red);">Aree di Miglioramento</h4><ul>`;
|
|
analysis.weaknesses.forEach(w => { html += `<li style="margin-bottom:4px;">${escapeHtml(w)}</li>`; });
|
|
html += '</ul></div>';
|
|
}
|
|
|
|
if (analysis.recommendations && analysis.recommendations.length > 0) {
|
|
html += `<div class="ai-block"><h4 style="color:var(--c-blue);">Raccomandazioni</h4><ol>`;
|
|
analysis.recommendations.forEach(r => { html += `<li style="margin-bottom:8px;">${escapeHtml(r)}</li>`; });
|
|
html += '</ol></div>';
|
|
}
|
|
|
|
if (!html) {
|
|
html = `<p style="color:var(--muted); line-height:1.7;">${escapeHtml(analysis.text || analysis.message || JSON.stringify(analysis))}</p>`;
|
|
}
|
|
|
|
content.innerHTML = html;
|
|
showNotification('Analisi AI completata.', 'success');
|
|
} else {
|
|
showNotification(result.message || 'Errore nell\'analisi AI.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
}
|
|
|
|
// ── Generate NCRs from Assessment Gaps ─────────────────────
|
|
async function generateNCRs() {
|
|
if (!currentAssessmentId) {
|
|
showNotification('Nessun assessment selezionato.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('btn-generate-ncr');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner"></span> Generazione in corso...';
|
|
|
|
try {
|
|
const result = await api.createNCRsFromAssessment(currentAssessmentId);
|
|
const resultDiv = document.getElementById('ncr-generation-result');
|
|
resultDiv.style.display = 'block';
|
|
|
|
if (result.success && result.data) {
|
|
const total = result.data.total || 0;
|
|
if (total > 0) {
|
|
const majorCount = (result.data.created || []).filter(n => n.severity === 'major').length;
|
|
const minorCount = total - majorCount;
|
|
resultDiv.innerHTML = `
|
|
<div style="background:#E6F4EC; border:1px solid #bbf7d0; border-radius:10px; padding:16px;">
|
|
<strong style="color:#15803d;">${total} non conformità generate con successo</strong>
|
|
<div style="margin-top:8px; font-size:12.5px; color:var(--muted);">
|
|
${majorCount > 0 ? `<span style="color:var(--c-red); font-weight:600;">${majorCount} major</span> ` : ''}
|
|
${minorCount > 0 ? `<span style="color:var(--c-amber); font-weight:600;">${minorCount} minor</span>` : ''}
|
|
</div>
|
|
</div>`;
|
|
showNotification(`${total} non conformità generate.`, 'success');
|
|
} else {
|
|
resultDiv.innerHTML = '<p class="text-muted">Nessun gap trovato — tutti i controlli sono implementati.</p>';
|
|
showNotification('Nessun gap trovato.', 'info');
|
|
}
|
|
btn.style.display = 'none';
|
|
} else {
|
|
resultDiv.innerHTML = `<p style="color:var(--c-red);">${escapeHtml(result.message || 'Errore nella generazione.')}</p>`;
|
|
showNotification(result.message || 'Errore.', 'error');
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i data-lucide="file-plus" class="ic"></i> Genera Non Conformità dai Gap';
|
|
refreshIcons();
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i data-lucide="file-plus" class="ic"></i> Genera Non Conformità dai Gap';
|
|
refreshIcons();
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|