Files
nis2-agile/public/assessment.html
T
DevEnv nis2-agileandClaude Opus 4.8 f6d2076684 [FEAT] Rischi #500 opzione B: matrice dai rischi-requisito (probabilità×impatto)
Ogni rischio derivato da un requisito puo' ora essere valutato con Probabilita' e
Impatto (1-5) e compare nella matrice 5x5.
- DB: mig 066 (org_requisito_state += likelihood/impact) + runner idempotente.
- Backend: FrameworkController::setState accetta likelihood/impact; RiskController
  ::derivedList ritorna likelihood/impact/score. Applicata su prod + reload.
- Frontend risks.html: colonne Probabilita'/Impatto/Rischio(P×I) + tasto "Valuta"
  (modale, riservato a super_admin/org_admin/compliance_manager); matrice calcolata
  client-side dai rischi derivati valutati (esclusi i non_applicabile, caveat
  nis2-expert) + rischi custom. Framing normativo invariato.
- help.js/i18n aggiornati; version 1.25.7; buster help/i18n ?v=20260727b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 15:32:59 +02:00

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=20260727r"></script>
<script src="/js/common.js?v=20260627t"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260627t"></script>
<script src="/js/i18n.js?v=20260727b"></script>
<script src="/js/help.js?v=20260727b"></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>