nis2-agile/public/acn-gap.html
DevEnv nis2-agile 91043d7391 [FEAT] Gap Analysis ACN: frontend (acn-gap.html + sidebar + api.js)
- public/acn-gap.html: catalogo requisiti consultabile, wizard per funzione FW
  con accordion misure/requisiti, opzioni attuato/parziale/non attuato/N.A.,
  autosave batch debounce, risultati con punteggio per funzione + piano d'azione
  gap + analisi AI. Badge livello soggetto (importante/essenziale). IT/EN inline.
- api.js: metodi acn* (catalog/list/create/requirements/respond/complete/report/
  aiAnalyze) che spacchettano l'envelope e lanciano errore su success=false.
- common.js: voce sidebar "Gap Analysis ACN".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:10:50 +02:00

410 lines
22 KiB
HTML
Raw 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>Gap Analysis ACN - NIS2 Agile</title>
<link rel="stylesheet" href="css/style.css">
<style>
.acn-intro { background:#eff6ff; border-left:4px solid var(--primary, #2563eb); padding:14px 18px; border-radius:8px; margin-bottom:20px; font-size:.92rem; line-height:1.6; }
.acn-level-badge { display:inline-block; padding:4px 12px; border-radius:999px; font-size:.8rem; font-weight:700; }
.acn-level-important { background:#fef3c7; color:#92400e; }
.acn-level-essential { background:#fee2e2; color:#991b1b; }
.acn-func { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; margin-bottom:14px; overflow:hidden; }
.acn-func-head { background:var(--gray-50,#f9fafb); padding:12px 16px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; gap:12px; }
.acn-func-head strong { font-size:1rem; }
.acn-func-meta { font-size:.8rem; color:var(--gray-500,#6b7280); }
.acn-func-body { padding:8px 16px 16px; display:none; }
.acn-func.open .acn-func-body { display:block; }
.acn-measure { border-top:1px solid var(--gray-100,#f3f4f6); padding:14px 0; }
.acn-measure-code { display:inline-block; background:#eef2ff; color:#3730a3; font-weight:700; font-size:.72rem; padding:2px 8px; border-radius:6px; margin-right:8px; }
.acn-measure-title { font-weight:600; font-size:.92rem; }
.acn-req { padding:12px 0 12px 14px; border-left:2px solid var(--gray-100,#f3f4f6); margin:10px 0 10px 8px; }
.acn-req-text { font-size:.9rem; margin-bottom:8px; }
.acn-req-idx { color:var(--gray-400,#9ca3af); font-weight:700; margin-right:6px; }
.acn-opts { display:flex; flex-wrap:wrap; gap:6px; }
.acn-opt { min-height:38px; padding:6px 12px; border:1.5px solid var(--gray-200,#e5e7eb); border-radius:20px; background:#fff; cursor:pointer; font-size:.84rem; font-weight:600; }
.acn-opt.sel-implemented { background:#16a34a; color:#fff; border-color:#16a34a; }
.acn-opt.sel-partial { background:#f59e0b; color:#fff; border-color:#f59e0b; }
.acn-opt.sel-not_implemented { background:#dc2626; color:#fff; border-color:#dc2626; }
.acn-opt.sel-not_applicable { background:#6b7280; color:#fff; border-color:#6b7280; }
.acn-progress { position:sticky; top:0; background:#fff; z-index:5; padding:12px 0; border-bottom:1px solid var(--gray-100,#f3f4f6); }
.acn-bar { height:8px; background:var(--gray-100,#f3f4f6); border-radius:4px; overflow:hidden; margin:8px 0; }
.acn-bar-fill { height:100%; background:var(--primary,#2563eb); width:0; transition:width .3s; }
.acn-func-score { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--gray-100,#f3f4f6); font-size:.9rem; }
.acn-score-pill { font-weight:700; padding:2px 10px; border-radius:6px; }
.savehint { font-size:.78rem; color:var(--gray-400,#9ca3af); min-height:16px; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="acn.title">Gap Analysis ACN (misure di base)</h2>
<div class="content-header-actions">
<button class="btn btn-outline btn-sm" id="btn-ai-analyze" style="display:none;" onclick="acnAiAnalyze()" data-i18n="acn.ai">Analisi AI dei gap</button>
</div>
</header>
<div class="content-body">
<div class="acn-intro" id="acn-intro">
<strong data-i18n="acn.intro.title">Conformità puntuale alla Determinazione ACN 164179/2025.</strong>
<span data-i18n="acn.intro.body">Questa analisi va oltre le 10 misure generiche dell'Art. 21: valuta le misure e i requisiti puntuali stabiliti dall'ACN per i soggetti NIS. Il perimetro dipende dalla classificazione del soggetto (importante o essenziale).</span>
</div>
<!-- START -->
<div id="acn-start" class="card mb-24">
<div class="card-body" style="padding:32px; text-align:center;">
<div id="acn-level-info" class="mb-24"></div>
<div id="acn-existing" class="mb-24" style="display:none; text-align:left;"></div>
<button class="btn btn-primary btn-lg" id="btn-new-acn" onclick="createAcn()" data-i18n="acn.new">Nuova Gap Analysis ACN</button>
<div style="margin-top:14px;">
<button class="btn btn-outline btn-sm" onclick="showCatalog()" data-i18n="acn.catalog.btn">Consulta il catalogo dei requisiti</button>
</div>
</div>
</div>
<!-- CATALOG (read-only) -->
<div id="acn-catalog" class="card mb-24" style="display:none;">
<div class="card-header"><h3 data-i18n="acn.catalog.title">Catalogo requisiti ACN applicabili</h3></div>
<div class="card-body" id="acn-catalog-body"></div>
</div>
<!-- WIZARD -->
<div id="acn-wizard" style="display:none;">
<div class="card acn-progress">
<div class="card-body" style="padding:14px 18px;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<strong id="acn-wizard-title">Gap Analysis ACN</strong>
<span id="acn-wizard-level"></span>
</div>
<div class="acn-bar"><div class="acn-bar-fill" id="acn-bar-fill"></div></div>
<div style="font-size:.82rem; color:var(--gray-500,#6b7280);"><span id="acn-answered">0</span> / <span id="acn-total">0</span> <span data-i18n="acn.answered">requisiti con risposta</span></div>
<div class="savehint" id="acn-savehint"></div>
</div>
</div>
<div class="card mb-24">
<div class="card-body" id="acn-functions"></div>
</div>
<div class="card mb-24">
<div class="card-body" style="text-align:center;">
<div id="acn-wizard-msg"></div>
<button class="btn btn-primary btn-lg" id="btn-acn-complete" onclick="completeAcn()" data-i18n="acn.complete">Calcola punteggio e completa</button>
</div>
</div>
</div>
<!-- RESULTS -->
<div id="acn-results" style="display:none;">
<div class="card mb-24">
<div class="card-header"><h3 data-i18n="acn.results.title">Esito Gap Analysis ACN</h3></div>
<div class="card-body">
<div style="text-align:center; margin-bottom:20px;">
<div style="font-size:3rem; font-weight:800;" id="acn-overall">0</div>
<div class="text-muted" data-i18n="acn.results.overall">Punteggio complessivo di conformità (0-100)</div>
</div>
<h4 style="margin:16px 0 8px;" data-i18n="acn.results.byfunc">Punteggio per funzione (Framework Nazionale)</h4>
<div id="acn-func-scores"></div>
</div>
</div>
<div class="card mb-24" id="acn-ai-card" style="display:none;">
<div class="card-header"><h3 data-i18n="acn.results.ai">Analisi AI dei gap</h3></div>
<div class="card-body" id="acn-ai-body"></div>
</div>
<div class="card mb-24">
<div class="card-header"><h3 data-i18n="acn.results.gaps">Requisiti non conformi (piano d'azione)</h3></div>
<div class="card-body" id="acn-gaps"></div>
</div>
<div style="text-align:center; margin-bottom:24px;">
<button class="btn btn-outline" onclick="location.reload()" data-i18n="acn.backstart">Torna all'inizio</button>
</div>
</div>
</div>
</main>
</div>
<script src="js/i18n.js"></script>
<script src="js/common.js"></script>
<script src="js/api.js"></script>
<script src="js/help.js"></script>
<script>
'use strict';
const FUNC_LABELS = {
GOVERN: { it:'Governo (GV)', en:'Govern (GV)' },
IDENTIFY: { it:'Identificazione (ID)', en:'Identify (ID)' },
PROTECT: { it:'Protezione (PR)', en:'Protect (PR)' },
DETECT: { it:'Rilevamento (DE)', en:'Detect (DE)' },
RESPOND: { it:'Risposta (RS)', en:'Respond (RS)' },
RECOVER: { it:'Ripristino (RC)', en:'Recover (RC)' }
};
let ACN = { id:null, level:null, answers:{}, total:0 };
let saveTimer = null;
function lang(){ try { return I18n.getLang ? I18n.getLang() : 'it'; } catch(e){ return 'it'; } }
function el(id){ return document.getElementById(id); }
function esc(s){ const d=document.createElement('div'); d.textContent=s==null?'':String(s); return d.innerHTML; }
document.addEventListener('DOMContentLoaded', async function(){
if (typeof checkAuth==='function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar==='function') loadSidebar();
if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
await initStart();
});
async function initStart(){
try {
const cat = await api.acnCatalog();
ACN.level = cat.entity_level;
const lvlTxt = cat.entity_level === 'essential'
? '<span class="acn-level-badge acn-level-essential">Soggetto ESSENZIALE</span>'
: '<span class="acn-level-badge acn-level-important">Soggetto IMPORTANTE</span>';
el('acn-level-info').innerHTML = lvlTxt +
'<p class="text-muted" style="margin-top:10px;">' +
(lang()==='en'?'Applicable scope: ':'Perimetro applicabile: ') +
'<strong>'+cat.totals.measures+'</strong> '+(lang()==='en'?'measures':'misure')+', <strong>'+
cat.totals.requirements+'</strong> '+(lang()==='en'?'requirements':'requisiti')+'.</p>';
window._acnCatalog = cat;
} catch(e){
if (e && e.error_code === 'ENTITY_LEVEL_REQUIRED') {
el('acn-level-info').innerHTML = '<p class="text-muted">'+(lang()==='en'
?'Classify the organization as important or essential first.'
:'Classifica prima il soggetto come importante o essenziale (Impostazioni Classificazione NIS2).')+'</p>';
} else {
el('acn-level-info').innerHTML = '<p class="text-muted">'+(lang()==='en'?'Could not load.':'Caricamento non riuscito.')+'</p>';
}
}
try {
const list = await api.acnList();
if (list.assessments && list.assessments.length){
let html = '<h4 style="margin-bottom:8px;">'+(lang()==='en'?'Existing assessments':'Assessment esistenti')+'</h4>';
list.assessments.forEach(function(a){
const st = a.status==='completed'
? '<span class="badge badge-success">'+(lang()==='en'?'Completed':'Completato')+(a.overall_score!=null?(' · '+a.overall_score):'')+'</span>'
: '<span class="badge badge-warning">'+(lang()==='en'?'In progress':'In corso')+'</span>';
html += '<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid var(--gray-100,#f3f4f6);">'+
'<div><strong>'+esc(a.title)+'</strong> '+st+'</div>'+
'<button class="btn btn-sm btn-outline" onclick="openAcn('+a.id+',\''+a.status+'\')">'+
(a.status==='completed'?(lang()==='en'?'View':'Vedi'):(lang()==='en'?'Resume':'Riprendi'))+'</button></div>';
});
el('acn-existing').innerHTML = html;
el('acn-existing').style.display='block';
}
} catch(e){}
}
async function showCatalog(){
const cat = window._acnCatalog;
if (!cat){ return; }
let html = '';
(cat.functions||[]).forEach(function(f){
html += '<div class="acn-func open"><div class="acn-func-head" onclick="this.parentNode.classList.toggle(\'open\')">'+
'<strong>'+esc(FUNC_LABELS[f.function_code]?FUNC_LABELS[f.function_code][lang()]:f.function_name)+'</strong>'+
'<span class="acn-func-meta">'+(f.measures||[]).length+' '+(lang()==='en'?'measures':'misure')+'</span></div>'+
'<div class="acn-func-body">';
(f.measures||[]).forEach(function(m){
html += '<div class="acn-measure"><span class="acn-measure-code">'+esc(m.measure_code)+'</span>'+
'<span class="acn-measure-title">'+esc(m.measure_title)+'</span>';
(m.requirements||[]).forEach(function(r){
html += '<div class="acn-req"><span class="acn-req-idx">'+r.index+'.</span>'+esc(r.text)+'</div>';
});
html += '</div>';
});
html += '</div></div>';
});
el('acn-catalog-body').innerHTML = html;
el('acn-catalog').style.display='block';
el('acn-catalog').scrollIntoView({behavior:'smooth'});
}
async function createAcn(){
try {
const res = await api.acnCreate({});
await openAcn(res.id, 'draft');
} catch(e){
alert((e && e.message) || (lang()==='en'?'Creation failed':'Creazione non riuscita'));
}
}
async function openAcn(id, status){
ACN.id = id;
const res = await api.acnRequirements(id);
ACN.level = res.assessment.entity_level;
if (res.assessment.status === 'completed'){
await showResults(id);
return;
}
renderWizard(res);
}
function renderWizard(res){
el('acn-start').style.display='none';
el('acn-catalog').style.display='none';
el('acn-results').style.display='none';
el('acn-wizard').style.display='block';
el('btn-ai-analyze').style.display='none';
el('acn-wizard-level').innerHTML = ACN.level==='essential'
? '<span class="acn-level-badge acn-level-essential">ESSENZIALE</span>'
: '<span class="acn-level-badge acn-level-important">IMPORTANTE</span>';
ACN.answers = {}; ACN.total = 0;
let html = '';
(res.functions||[]).forEach(function(f, fi){
let fcount = 0;
let inner = '';
(f.categories||[]).forEach(function(c){
(c.measures||[]).forEach(function(m){
inner += '<div class="acn-measure"><span class="acn-measure-code">'+esc(m.measure_code)+'</span>'+
'<span class="acn-measure-title">'+esc(m.measure_title)+'</span>';
(m.requirements||[]).forEach(function(r){
ACN.total++;
fcount++;
if (r.response) ACN.answers[r.key] = r.response;
inner += renderReq(r);
});
inner += '</div>';
});
});
html += '<div class="acn-func'+(fi===0?' open':'')+'">'+
'<div class="acn-func-head" onclick="this.parentNode.classList.toggle(\'open\')">'+
'<strong>'+esc(FUNC_LABELS[f.function_code]?FUNC_LABELS[f.function_code][lang()]:f.function_name)+'</strong>'+
'<span class="acn-func-meta">'+fcount+' '+(lang()==='en'?'requirements':'requisiti')+'</span></div>'+
'<div class="acn-func-body">'+inner+'</div></div>';
});
el('acn-functions').innerHTML = html;
el('acn-total').textContent = ACN.total;
updateProgress();
}
function renderReq(r){
const opts = [
['implemented', lang()==='en'?'Implemented':'Attuato'],
['partial', lang()==='en'?'Partial':'Parziale'],
['not_implemented', lang()==='en'?'Not implemented':'Non attuato'],
['not_applicable', lang()==='en'?'N/A':'Non applic.']
];
let btns = '';
opts.forEach(function(o){
const sel = r.response===o[0] ? ('sel-'+o[0]) : '';
btns += '<button type="button" class="acn-opt '+sel+'" data-key="'+esc(r.key)+'" data-val="'+o[0]+'" onclick="pick(this)">'+o[1]+'</button>';
});
return '<div class="acn-req"><div class="acn-req-text"><span class="acn-req-idx">'+r.index+'.</span>'+esc(r.text)+'</div>'+
'<div class="acn-opts">'+btns+'</div></div>';
}
function pick(btn){
const key = btn.getAttribute('data-key');
const val = btn.getAttribute('data-val');
const wrap = btn.parentNode;
Array.prototype.forEach.call(wrap.querySelectorAll('.acn-opt'), function(b){
b.className = 'acn-opt';
});
btn.className = 'acn-opt sel-'+val;
ACN.answers[key] = val;
updateProgress();
scheduleSave(key, val);
}
let pendingSaves = {};
function scheduleSave(key, val){
pendingSaves[key] = val;
el('acn-savehint').textContent = lang()==='en'?'Saving…':'Salvataggio…';
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(flushSaves, 700);
}
async function flushSaves(){
const batch = Object.keys(pendingSaves).map(function(k){ return { requirement_key:k, response_value:pendingSaves[k] }; });
if (!batch.length) return;
pendingSaves = {};
try {
await api.acnRespond(ACN.id, { responses: batch });
el('acn-savehint').textContent = (lang()==='en'?'Saved ':'Salvato ')+new Date().toLocaleTimeString(lang()==='en'?'en-GB':'it-IT').slice(0,5);
} catch(e){
el('acn-savehint').textContent = lang()==='en'?'Save pending':'Salvataggio in sospeso';
}
}
function updateProgress(){
const answered = Object.keys(ACN.answers).length;
el('acn-answered').textContent = answered;
el('acn-bar-fill').style.width = (ACN.total? Math.round(answered/ACN.total*100):0)+'%';
}
async function completeAcn(){
await flushSaves();
const answered = Object.keys(ACN.answers).length;
if (answered < ACN.total){
el('acn-wizard-msg').innerHTML = '<div class="alert alert-warning">'+
(lang()==='en'?('Missing '+(ACN.total-answered)+' answers.'):('Mancano '+(ACN.total-answered)+' risposte.'))+'</div>';
return;
}
try {
await api.acnComplete(ACN.id);
await showResults(ACN.id);
} catch(e){
el('acn-wizard-msg').innerHTML = '<div class="alert alert-warning">'+esc((e&&e.message)||'Errore')+'</div>';
}
}
async function showResults(id){
const rep = await api.acnReport(id);
const a = rep.assessment;
el('acn-start').style.display='none';
el('acn-wizard').style.display='none';
el('acn-catalog').style.display='none';
el('acn-results').style.display='block';
el('btn-ai-analyze').style.display='inline-flex';
ACN.id = id;
el('acn-overall').textContent = a.overall_score!=null ? a.overall_score : '—';
el('acn-overall').style.color = scoreColor(a.overall_score);
let fs = '';
const fscores = a.function_scores || {};
Object.keys(FUNC_LABELS).forEach(function(fc){
if (fscores[fc]==null) return;
fs += '<div class="acn-func-score"><span>'+esc(FUNC_LABELS[fc][lang()])+'</span>'+
'<span class="acn-score-pill" style="background:'+scoreBg(fscores[fc])+'; color:'+scoreColor(fscores[fc])+';">'+fscores[fc]+'</span></div>';
});
el('acn-func-scores').innerHTML = fs;
let g = '';
(rep.gaps||[]).forEach(function(gap){
const tag = gap.response_value==='not_implemented'
? '<span class="badge badge-danger">'+(lang()==='en'?'Not implemented':'Non attuato')+'</span>'
: '<span class="badge badge-warning">'+(lang()==='en'?'Partial':'Parziale')+'</span>';
g += '<div style="padding:10px 0; border-bottom:1px solid var(--gray-100,#f3f4f6);">'+
'<span class="acn-measure-code">'+esc(gap.measure_code)+'</span> '+tag+
'<div style="font-size:.88rem; margin-top:4px;">'+esc(gap.requirement_text)+'</div></div>';
});
el('acn-gaps').innerHTML = g || '<p class="text-muted">'+(lang()==='en'?'No gaps. Fully compliant.':'Nessun gap: pienamente conforme.')+'</p>';
if (a.ai_summary){
el('acn-ai-body').innerHTML = '<div style="white-space:pre-wrap; font-size:.9rem;">'+esc(a.ai_summary)+'</div>';
el('acn-ai-card').style.display='block';
}
}
async function acnAiAnalyze(){
const btn = el('btn-ai-analyze');
btn.disabled = true;
const old = btn.textContent;
btn.textContent = lang()==='en'?'Analyzing…':'Analisi in corso…';
try {
const res = await api.acnAiAnalyze(ACN.id);
el('acn-ai-body').innerHTML = '<div style="white-space:pre-wrap; font-size:.9rem;">'+esc(res.ai_summary)+'</div>';
el('acn-ai-card').style.display='block';
el('acn-ai-card').scrollIntoView({behavior:'smooth'});
} catch(e){
alert((e&&e.message)||(lang()==='en'?'AI unavailable':'AI non disponibile'));
} finally {
btn.disabled=false; btn.textContent=old;
}
}
function scoreColor(s){ if(s==null)return '#6b7280'; if(s>=80)return '#16a34a'; if(s>=60)return '#65a30d'; if(s>=40)return '#f59e0b'; return '#dc2626'; }
function scoreBg(s){ if(s==null)return '#f3f4f6'; if(s>=80)return '#f0fdf4'; if(s>=60)return '#f7fee7'; if(s>=40)return '#fffbeb'; return '#fef2f2'; }
</script>
</body>
</html>