[CLEANUP] Unica UI: rimossi 28 duplicati *-bi.html (orfani post-swap Bootstrap Italia, 0 riferimenti). Le pagine ufficiali (nomi senza -bi) SONO la UI V2 BI

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-15 17:17:34 +02:00
co-authored by Claude Opus 4.8
parent 717f9ac396
commit 3c5184bbae
28 changed files with 0 additions and 18692 deletions
-451
View File
@@ -1,451 +0,0 @@
<!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 ACN (BI) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (griglia misure/requisiti, scoring) -> parita' funzionale assoluta.
BI veste la chrome (sidebar, font, bottoni, icone sprite). -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260613b">
<style>
/* Stili specifici della pagina ACN — identici alla live acn-gap.html
(variabili --primary/--gray-* del design system esistente) */
.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:focus-visible { outline:2px solid var(--primary,#2563eb); outline-offset:2px; }
.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; }
/* icona inline nei bottoni BI */
.btn .icon { fill:currentColor; vertical-align:-2px; margin-right:6px; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="acn.title">Gap Analysis ACN (misure di base)</h1>
<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">
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-star-full"></use></svg>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">
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-plus"></use></svg>Nuova Gap Analysis ACN</button>
<div style="margin-top:14px;">
<button class="btn btn-outline btn-sm" onclick="showCatalog()" data-i18n="acn.catalog.btn">
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-search"></use></svg>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" role="status" aria-live="polite"></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">
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-chart-line"></use></svg>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">
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-left"></use></svg>Torna all'inizio</button>
</div>
</div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260613"></script>
<script src="/js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260615"></script>
<script src="/js/i18n.js?v=20260615"></script>
<script src="/js/help.js?v=20260615"></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">'+(lang()==='en'?'ESSENTIAL entity':'Soggetto ESSENZIALE')+'</span>'
: '<span class="acn-level-badge acn-level-important">'+(lang()==='en'?'IMPORTANT entity':'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){
try {
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);
} catch(e){
alert((e && e.message) || (lang()==='en'?'Could not open the assessment.':'Impossibile aprire l\'assessment.'));
}
}
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">'+(lang()==='en'?'ESSENTIAL':'ESSENZIALE')+'</span>'
: '<span class="acn-level-badge acn-level-important">'+(lang()==='en'?'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>
-411
View File
@@ -1,411 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Pannello Amministrazione (BI) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css resta autorevole per i widget renderizzati via JS -> parita' funzionale.
Pagina in /admin/: gli asset usano path ASSOLUTI (/vendor, /js, /css) serviti dal web root. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260613b">
<style>
.admin-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.admin-stat-card {
background: var(--card-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--card-shadow);
padding: 20px;
transition: box-shadow var(--transition), transform var(--transition);
}
.admin-stat-card:hover {
box-shadow: var(--card-shadow-hover);
transform: translateY(-2px);
}
.admin-stat-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--gray-500);
margin-bottom: 8px;
}
.admin-stat-value {
font-size: 2rem;
font-weight: 800;
color: var(--gray-900);
line-height: 1;
}
.admin-stat-icon {
width: 40px;
height: 40px;
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.admin-stat-icon svg {
width: 20px;
height: 20px;
}
.admin-stat-icon.blue { background: var(--primary-bg); color: var(--primary); }
.admin-stat-icon.green { background: var(--secondary-bg); color: var(--secondary); }
.admin-stat-icon.orange { background: var(--warning-bg); color: #d97706; }
.admin-stat-icon.red { background: var(--danger-bg); color: var(--danger); }
.admin-stat-icon.purple { background: #f3e8ff; color: #7c3aed; }
/* Le icone BI usano fill=currentColor: garantiamo che il colore del wrapper sia ereditato. */
.admin-stat-icon svg.icon { fill: currentColor; width: 20px; height: 20px; }
.chart-container {
padding: 24px;
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 32px;
height: 200px;
padding: 0 20px;
border-bottom: 2px solid var(--gray-200);
}
.bar-chart-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
height: 100%;
justify-content: flex-end;
}
.bar-chart-bar {
width: 60px;
border-radius: 6px 6px 0 0;
transition: height 0.8s ease;
min-height: 4px;
position: relative;
}
.bar-chart-bar.free { background: var(--gray-400); }
.bar-chart-bar.professional { background: var(--primary); }
.bar-chart-bar.enterprise { background: #7c3aed; }
.bar-chart-value {
font-size: 0.875rem;
font-weight: 700;
color: var(--gray-700);
margin-bottom: 8px;
}
.bar-chart-label {
font-size: 0.75rem;
color: var(--gray-500);
margin-top: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.quick-link-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius-lg);
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
color: inherit;
}
.quick-link-card:hover {
border-color: var(--primary);
background: var(--primary-bg);
color: var(--primary);
transform: translateY(-2px);
box-shadow: var(--card-shadow-hover);
}
.quick-link-card:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.quick-link-icon {
width: 48px;
height: 48px;
border-radius: var(--border-radius);
background: var(--primary-bg);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.quick-link-icon svg {
width: 24px;
height: 24px;
fill: currentColor;
}
.quick-link-info h4 {
font-size: 0.9375rem;
font-weight: 600;
color: var(--gray-800);
margin-bottom: 2px;
}
.quick-link-info p {
font-size: 0.8125rem;
color: var(--gray-500);
}
.quick-link-card:hover .quick-link-info h4 {
color: var(--primary);
}
.quick-links-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
@media (max-width: 1024px) {
.admin-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.admin-stats-grid {
grid-template-columns: 1fr;
}
.quick-links-grid {
grid-template-columns: 1fr;
}
.bar-chart {
gap: 16px;
}
.bar-chart-bar {
width: 40px;
}
}
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h1 class="h2">Pannello Amministrazione</h1>
<div class="content-header-actions">
<span class="badge badge-danger">Super Admin</span>
</div>
</header>
<div class="content-body">
<!-- Stats Row 1 -->
<div class="admin-stats-grid" id="stats-row-1">
<div class="admin-stat-card">
<div class="admin-stat-icon blue" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-total-orgs">Totale Organizzazioni</div>
<div class="admin-stat-value" id="stat-total-orgs" aria-labelledby="lbl-total-orgs">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon green" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check-circle"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-active-orgs">Organizzazioni Attive</div>
<div class="admin-stat-value" id="stat-active-orgs" aria-labelledby="lbl-active-orgs">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon blue" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-total-users">Totale Utenti</div>
<div class="admin-stat-value" id="stat-total-users" aria-labelledby="lbl-total-users">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon green" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-active-users">Utenti Attivi</div>
<div class="admin-stat-value" id="stat-active-users" aria-labelledby="lbl-active-users">--</div>
</div>
</div>
<!-- Stats Row 2 -->
<div class="admin-stats-grid" id="stats-row-2">
<div class="admin-stat-card">
<div class="admin-stat-icon purple" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-assessments">Assessment Completati</div>
<div class="admin-stat-value" id="stat-assessments" aria-labelledby="lbl-assessments">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon orange" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning-circle"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-incidents">Incidenti Aperti</div>
<div class="admin-stat-value" id="stat-incidents" aria-labelledby="lbl-incidents">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon red" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-error"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-risks">Totale Rischi</div>
<div class="admin-stat-value" id="stat-risks" aria-labelledby="lbl-risks">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon purple" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-software"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-ai">Interazioni AI</div>
<div class="admin-stat-value" id="stat-ai" aria-labelledby="lbl-ai">--</div>
</div>
</div>
<!-- Plans Distribution -->
<div class="card mb-24">
<div class="card-header">
<h3>Distribuzione Piani</h3>
</div>
<div class="chart-container">
<div class="bar-chart" id="plans-chart" role="img" aria-label="Grafico a barre: distribuzione delle organizzazioni per piano di sottoscrizione">
<div class="spinner" style="margin:40px auto;"></div>
</div>
</div>
</div>
<!-- Quick Links -->
<div class="card">
<div class="card-header">
<h3>Gestione Piattaforma</h3>
</div>
<div class="card-body">
<nav class="quick-links-grid" aria-label="Collegamenti rapidi gestione piattaforma">
<a href="/admin/organizations-bi.html" class="quick-link-card">
<div class="quick-link-icon" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg>
</div>
<div class="quick-link-info">
<h4>Gestisci Organizzazioni</h4>
<p>Visualizza e gestisci tutte le organizzazioni registrate sulla piattaforma.</p>
</div>
</a>
<a href="/admin/users-bi.html" class="quick-link-card">
<div class="quick-link-icon" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
</div>
<div class="quick-link-info">
<h4>Gestisci Utenti</h4>
<p>Visualizza e gestisci tutti gli utenti registrati sulla piattaforma.</p>
</div>
</a>
</nav>
</div>
</div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260613"></script>
<script src="/js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260615"></script>
<script src="/js/i18n.js?v=20260615"></script>
<script>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
// ── Admin check ──────────────────────────────────────────
(async function checkAdmin() {
try {
const me = await api.getMe();
if (!me.success || !me.data || me.data.role !== 'super_admin') {
showNotification('Accesso non autorizzato. Solo gli amministratori possono accedere a questa pagina.', 'error');
setTimeout(() => { window.location.href = '/dashboard.html'; }, 1500);
return;
}
loadAdminStats();
} catch (e) {
window.location.href = '/dashboard.html';
}
})();
// ── Load Stats ───────────────────────────────────────────
async function loadAdminStats() {
try {
const result = await api.request('GET', '/admin/stats');
if (result.success && result.data) {
const d = result.data;
document.getElementById('stat-total-orgs').textContent = d.total_organizations ?? 0;
document.getElementById('stat-active-orgs').textContent = d.active_organizations ?? 0;
document.getElementById('stat-total-users').textContent = d.total_users ?? 0;
document.getElementById('stat-active-users').textContent = d.active_users ?? 0;
document.getElementById('stat-assessments').textContent = d.completed_assessments ?? 0;
document.getElementById('stat-incidents').textContent = d.open_incidents ?? 0;
document.getElementById('stat-risks').textContent = d.total_risks ?? 0;
document.getElementById('stat-ai').textContent = d.ai_interactions ?? 0;
renderPlansChart(d.plans_distribution || []);
} else {
showNotification('Impossibile caricare le statistiche.', 'error');
}
} catch (e) {
console.error('Errore caricamento stats:', e);
showNotification('Errore di connessione.', 'error');
}
}
// ── Plans Chart ──────────────────────────────────────────
function renderPlansChart(distribution) {
const container = document.getElementById('plans-chart');
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };
const plans = { free: 0, professional: 0, enterprise: 0 };
distribution.forEach(p => {
const key = (p.subscription_plan || 'free').toLowerCase();
if (plans.hasOwnProperty(key)) {
plans[key] = parseInt(p.count) || 0;
}
});
const maxVal = Math.max(1, ...Object.values(plans));
let html = '';
for (const [key, count] of Object.entries(plans)) {
const heightPct = (count / maxVal) * 100;
html += `
<div class="bar-chart-item">
<div class="bar-chart-value">${count}</div>
<div class="bar-chart-bar ${key}" style="height:${Math.max(heightPct, 3)}%;"></div>
<div class="bar-chart-label">${planLabels[key]}</div>
</div>`;
}
container.innerHTML = html;
}
</script>
</body>
</html>
-328
View File
@@ -1,328 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gestione Organizzazioni (BI) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (tabella org/paginazione/badge) -> parita' funzionale assoluta. BI veste la chrome.
Path relativi ../ perche' la pagina vive sotto public/admin/. -->
<link rel="stylesheet" href="../vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="../css/style.css?v=20260613b">
<style>
.entity-type-badge {
display: inline-flex;
padding: 3px 10px;
border-radius: 100px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.entity-type-badge.essential { background: var(--danger-bg); color: var(--danger); }
.entity-type-badge.important { background: var(--warning-bg); color: #a16207; }
.entity-type-badge.not_applicable { background: var(--gray-100); color: var(--gray-600); }
.plan-badge {
display: inline-flex;
padding: 3px 10px;
border-radius: 100px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.plan-badge.free { background: var(--gray-100); color: var(--gray-600); }
.plan-badge.professional { background: var(--primary-bg); color: var(--primary); }
.plan-badge.enterprise { background: #f3e8ff; color: #7c3aed; }
.status-dot {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
}
.status-dot::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.active::before { background: var(--secondary); }
.status-dot.inactive::before { background: var(--gray-400); }
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid var(--gray-100);
background: var(--gray-50);
}
.pagination-info {
font-size: 0.8125rem;
color: var(--gray-500);
}
.pagination-controls {
display: flex;
gap: 8px;
}
.score-inline {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
font-weight: 600;
}
.score-inline .score-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.admin-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--gray-500);
margin-bottom: 20px;
}
.admin-breadcrumb a {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.admin-breadcrumb a:hover {
text-decoration: underline;
}
.admin-breadcrumb .icon {
width: 14px;
height: 14px;
color: var(--gray-400);
fill: currentColor;
}
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h1 class="h2">Gestione Organizzazioni</h1>
<div class="content-header-actions">
<span class="badge badge-danger">Super Admin</span>
</div>
</header>
<div class="content-body">
<!-- Breadcrumb -->
<nav class="admin-breadcrumb" aria-label="Percorso di navigazione">
<a href="../admin/index.html">Amministrazione</a>
<svg class="icon icon-sm" aria-hidden="true"><use href="../vendor/bootstrap-italia/dist/svg/sprites.svg#it-chevron-right"></use></svg>
<span aria-current="page">Organizzazioni</span>
</nav>
<!-- Table -->
<div class="card">
<div class="card-body" style="padding:0;">
<div id="orgs-container">
<div class="spinner" style="margin:60px auto;" role="status" aria-label="Caricamento in corso"></div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="../js/api.js?v=20260613"></script>
<script src="../js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="../vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('../vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="../js/common-bi.js?v=20260615"></script>
<script src="../js/i18n.js?v=20260615"></script>
<script>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
let currentPage = 1;
// ── Admin check ──────────────────────────────────────────
(async function checkAdmin() {
try {
const me = await api.getMe();
if (!me.success || !me.data || me.data.role !== 'super_admin') {
showNotification('Accesso non autorizzato.', 'error');
setTimeout(() => { window.location.href = '/dashboard.html'; }, 1500);
return;
}
loadOrganizations(1);
} catch (e) {
window.location.href = '/dashboard.html';
}
})();
// ── Mappature ────────────────────────────────────────────
const sectorLabels = {
energy: 'Energia', transport: 'Trasporti', banking: 'Banche',
health: 'Sanita\'', water: 'Acqua', digital_infra: 'Infrastrutture Digitali',
public_admin: 'Pubblica Amministrazione', manufacturing: 'Manifatturiero',
postal: 'Servizi Postali', chemical: 'Chimico', food: 'Alimentare',
waste: 'Rifiuti', ict_services: 'Servizi ICT',
digital_providers: 'Provider Digitali', space: 'Spazio',
research: 'Ricerca', other: 'Altro'
};
const entityTypeLabels = {
essential: 'Essenziale',
important: 'Importante',
not_applicable: 'N/A'
};
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };
// ── Caricamento ──────────────────────────────────────────
async function loadOrganizations(page) {
const container = document.getElementById('orgs-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;" role="status" aria-label="Caricamento in corso"></div>';
currentPage = page;
try {
const result = await api.request('GET', '/admin/organizations?page=' + page);
if (result.success) {
renderOrganizations(result.data || [], result.pagination || {});
} else {
container.innerHTML = `
<div class="empty-state">
<h4>Errore nel caricamento</h4>
<p>${escapeHtml(result.message || 'Errore sconosciuto')}</p>
</div>`;
}
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
}
}
function renderOrganizations(orgs, pagination) {
const container = document.getElementById('orgs-container');
if (!orgs || orgs.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg class="icon" aria-hidden="true" style="width:48px;height:48px;fill:var(--gray-300);"><use href="../vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg>
<h4>Nessuna organizzazione trovata</h4>
<p>Le organizzazioni registrate appariranno qui.</p>
</div>`;
return;
}
let html = `
<div class="table-container">
<table>
<thead>
<tr>
<th scope="col">Nome</th>
<th scope="col">Settore</th>
<th scope="col">Tipo Entita'</th>
<th scope="col">Membri</th>
<th scope="col">Ultimo Score</th>
<th scope="col">Piano</th>
<th scope="col">Stato</th>
<th scope="col">Data Creazione</th>
</tr>
</thead>
<tbody>`;
orgs.forEach(org => {
const entityType = org.entity_type || 'not_applicable';
const sector = sectorLabels[org.sector] || org.sector || '-';
const entityLabel = entityTypeLabels[entityType] || entityType;
const plan = (org.subscription_plan || 'free').toLowerCase();
const planLabel = planLabels[plan] || plan;
const isActive = org.is_active == 1 || org.is_active === true;
const score = org.last_score;
let scoreHtml = '<span class="text-muted">-</span>';
if (score !== null && score !== undefined) {
const scoreColor = getScoreColor(score);
scoreHtml = `
<span class="score-inline">
<span class="score-dot" style="background:${scoreColor}"></span>
${Math.round(score)}%
</span>`;
}
html += `
<tr>
<td><strong>${escapeHtml(org.name || '-')}</strong></td>
<td>${escapeHtml(sector)}</td>
<td><span class="entity-type-badge ${entityType}">${escapeHtml(entityLabel)}</span></td>
<td>${org.member_count ?? '-'}</td>
<td>${scoreHtml}</td>
<td><span class="plan-badge ${plan}">${escapeHtml(planLabel)}</span></td>
<td><span class="status-dot ${isActive ? 'active' : 'inactive'}">${isActive ? 'Attiva' : 'Inattiva'}</span></td>
<td>${formatDate(org.created_at)}</td>
</tr>`;
});
html += '</tbody></table></div>';
// Paginazione
const total = pagination.total || orgs.length;
const perPage = pagination.per_page || 20;
const page = pagination.page || currentPage;
const totalPages = Math.ceil(total / perPage);
const start = ((page - 1) * perPage) + 1;
const end = Math.min(page * perPage, total);
if (totalPages > 1) {
html += `
<div class="pagination">
<div class="pagination-info">
Visualizzati ${start}-${end} di ${total} organizzazioni
</div>
<div class="pagination-controls">
<button class="btn btn-secondary btn-sm" onclick="loadOrganizations(${page - 1})" ${page <= 1 ? 'disabled' : ''}>
&laquo; Precedente
</button>
<button class="btn btn-secondary btn-sm" onclick="loadOrganizations(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
Successivo &raquo;
</button>
</div>
</div>`;
} else if (total > 0) {
html += `
<div class="pagination">
<div class="pagination-info">
Totale: ${total} organizzazion${total === 1 ? 'e' : 'i'}
</div>
<div></div>
</div>`;
}
container.innerHTML = html;
}
</script>
</body>
</html>
-400
View File
@@ -1,400 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gestione Utenti (BI) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (tabella utenti/azioni) -> parita' funzionale assoluta. BI veste la chrome.
Path ../ perche' questa pagina vive in /admin/. -->
<link rel="stylesheet" href="../vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="../css/style.css?v=20260613b">
<style>
.role-badge {
display: inline-flex;
padding: 3px 10px;
border-radius: 100px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.role-badge.super_admin { background: var(--danger-bg); color: var(--danger); }
.role-badge.org_admin { background: var(--primary-bg); color: var(--primary); }
.role-badge.compliance_manager { background: var(--secondary-bg); color: #15803d; }
.role-badge.board_member { background: #f3e8ff; color: #7c3aed; }
.role-badge.auditor { background: var(--info-bg); color: var(--primary); }
.role-badge.employee { background: var(--gray-100); color: var(--gray-600); }
.active-toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
}
.active-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
width: 36px;
height: 20px;
background: var(--gray-300);
border-radius: 10px;
transition: background var(--transition-fast);
position: relative;
}
.toggle-slider::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
top: 2px;
left: 2px;
transition: transform var(--transition-fast);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.active-toggle input:checked + .toggle-slider {
background: var(--secondary);
}
.active-toggle input:checked + .toggle-slider::after {
transform: translateX(16px);
}
/* Focus visibile WCAG su toggle keyboard-operabile */
.active-toggle input:focus-visible + .toggle-slider {
outline: 2px solid var(--primary, #06c);
outline-offset: 2px;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid var(--gray-100);
background: var(--gray-50);
}
.pagination-info {
font-size: 0.8125rem;
color: var(--gray-500);
}
.pagination-controls {
display: flex;
gap: 8px;
}
.org-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.org-tag {
display: inline-flex;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 500;
background: var(--gray-100);
color: var(--gray-600);
border-radius: var(--border-radius-sm);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--gray-500);
margin-bottom: 20px;
}
.admin-breadcrumb a {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.admin-breadcrumb a:hover {
text-decoration: underline;
}
.admin-breadcrumb .icon {
color: var(--gray-400);
}
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h1 class="h2">Gestione Utenti</h1>
<div class="content-header-actions">
<span class="badge badge-danger">Super Admin</span>
</div>
</header>
<div class="content-body">
<!-- Breadcrumb -->
<nav class="admin-breadcrumb" aria-label="Percorso di navigazione">
<a href="../admin/index.html">Amministrazione</a>
<svg class="icon icon-sm" aria-hidden="true"><use href="../vendor/bootstrap-italia/dist/svg/sprites.svg#it-chevron-right"></use></svg>
<span aria-current="page">Utenti</span>
</nav>
<!-- Table -->
<div class="card">
<div class="card-body" style="padding:0;">
<div id="users-container" aria-live="polite">
<div class="spinner" style="margin:60px auto;" role="status" aria-label="Caricamento utenti"></div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="../js/api.js?v=20260613"></script>
<script src="../js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="../vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('../vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="../js/common-bi.js?v=20260615"></script>
<script src="../js/i18n.js?v=20260615"></script>
<script>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
let currentPage = 1;
// ── Admin check ──────────────────────────────────────────
(async function checkAdmin() {
try {
const me = await api.getMe();
if (!me.success || !me.data || me.data.role !== 'super_admin') {
showNotification('Accesso non autorizzato.', 'error');
setTimeout(() => { window.location.href = '../dashboard.html'; }, 1500);
return;
}
loadUsers(1);
} catch (e) {
window.location.href = '../dashboard.html';
}
})();
// ── Mappature ────────────────────────────────────────────
const roleLabels = {
super_admin: 'Super Admin',
org_admin: 'Amministratore',
compliance_manager: 'Compliance Manager',
board_member: 'Membro CDA',
auditor: 'Auditor',
employee: 'Dipendente'
};
// ── Caricamento ──────────────────────────────────────────
async function loadUsers(page) {
const container = document.getElementById('users-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;" role="status" aria-label="Caricamento utenti"></div>';
currentPage = page;
try {
const result = await api.request('GET', '/admin/users?page=' + page);
if (result.success) {
renderUsers(result.data || [], result.pagination || {});
} else {
container.innerHTML = `
<div class="empty-state">
<h4>Errore nel caricamento</h4>
<p>${escapeHtml(result.message || 'Errore sconosciuto')}</p>
</div>`;
}
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
}
}
function renderUsers(users, pagination) {
const container = document.getElementById('users-container');
if (!users || users.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg class="icon icon-lg" aria-hidden="true" style="margin:0 auto;"><use href="../vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
<h4>Nessun utente trovato</h4>
<p>Gli utenti registrati appariranno qui.</p>
</div>`;
return;
}
let html = `
<div class="table-container">
<table>
<thead>
<tr>
<th scope="col">Nome</th>
<th scope="col">Email</th>
<th scope="col">Ruolo</th>
<th scope="col">Organizzazioni</th>
<th scope="col">Ultimo Login</th>
<th scope="col">Attivo</th>
<th scope="col">Data Creazione</th>
<th scope="col">Azioni</th>
</tr>
</thead>
<tbody>`;
users.forEach(user => {
const role = user.role || 'employee';
const roleLabel = roleLabels[role] || role;
const isActive = user.is_active == 1 || user.is_active === true;
// Organizzazioni
let orgsHtml = '<span class="text-muted">-</span>';
if (user.organizations) {
const orgNames = user.organizations.split(',').filter(n => n.trim());
if (orgNames.length > 0) {
orgsHtml = '<div class="org-tags">';
orgNames.forEach(name => {
orgsHtml += `<span class="org-tag" title="${escapeHtml(name.trim())}">${escapeHtml(name.trim())}</span>`;
});
orgsHtml += '</div>';
}
}
// Ultimo login
const lastLogin = user.last_login_at ? formatDateTime(user.last_login_at) : '<span class="text-muted">Mai</span>';
html += `
<tr>
<td><strong>${escapeHtml(user.full_name || '-')}</strong></td>
<td>${escapeHtml(user.email || '-')}</td>
<td><span class="role-badge ${role}">${escapeHtml(roleLabel)}</span></td>
<td>${orgsHtml}</td>
<td>${lastLogin}</td>
<td>
<label class="active-toggle" title="${isActive ? 'Attivo' : 'Inattivo'}">
<span class="visually-hidden">Stato attivo utente ${escapeHtml(user.email || '')}</span>
<input type="checkbox" ${isActive ? 'checked' : ''} onchange="toggleUserActive(${user.id}, this.checked)" ${role === 'super_admin' ? 'disabled' : ''} aria-label="${isActive ? 'Disattiva' : 'Attiva'} utente ${escapeHtml(user.email || '')}">
<span class="toggle-slider" aria-hidden="true"></span>
</label>
</td>
<td>${formatDate(user.created_at)}</td>
<td>
${role !== 'super_admin' && isActive
? `<button class="btn btn-secondary btn-sm" onclick="impersonateUser(${user.id}, '${escapeHtml(user.email)}')" title="Entra come questo utente per 1 ora">
<svg class="icon icon-sm" aria-hidden="true" style="vertical-align:-2px"><use href="../vendor/bootstrap-italia/dist/svg/sprites.svg#it-exchange-circle"></use></svg>
Impersonate
</button>`
: '<span class="text-muted">-</span>'}
</td>
</tr>`;
});
html += '</tbody></table></div>';
// Paginazione
const total = pagination.total || users.length;
const perPage = pagination.per_page || 20;
const page = pagination.page || currentPage;
const totalPages = Math.ceil(total / perPage);
const start = ((page - 1) * perPage) + 1;
const end = Math.min(page * perPage, total);
if (totalPages > 1) {
html += `
<div class="pagination">
<div class="pagination-info">
Visualizzati ${start}-${end} di ${total} utenti
</div>
<div class="pagination-controls">
<button class="btn btn-secondary btn-sm" onclick="loadUsers(${page - 1})" ${page <= 1 ? 'disabled' : ''}>
&laquo; Precedente
</button>
<button class="btn btn-secondary btn-sm" onclick="loadUsers(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
Successivo &raquo;
</button>
</div>
</div>`;
} else if (total > 0) {
html += `
<div class="pagination">
<div class="pagination-info">
Totale: ${total} utent${total === 1 ? 'e' : 'i'}
</div>
<div></div>
</div>`;
}
container.innerHTML = html;
}
// ── Toggle Attivo/Inattivo ───────────────────────────────
async function toggleUserActive(userId, isActive) {
try {
const result = await api.request('PUT', '/admin/users/' + userId, {
is_active: isActive ? 1 : 0
});
if (result.success) {
showNotification(
isActive ? 'Utente attivato con successo.' : 'Utente disattivato con successo.',
'success'
);
} else {
showNotification(result.message || 'Errore nell\'aggiornamento.', 'error');
// Ricarica per resettare lo stato
loadUsers(currentPage);
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
loadUsers(currentPage);
}
}
// ── Impersonate (Fase 4 / G11 — UI) ──
async function impersonateUser(userId, email) {
if (!confirm('Entrare come ' + email + ' per 1 ora?\n\nSarai loggato come quell\'utente. Tornerai alla tua sessione facendo logout.')) return;
try {
const res = await api.post('/auth/impersonate', { user_id: userId });
if (res.success && res.data && res.data.access_token) {
// Salva sessione originale per "Exit impersonate"
sessionStorage.setItem('nis2_impersonate_origin_token', api.token);
sessionStorage.setItem('nis2_impersonate_origin_refresh', api.refreshToken || '');
sessionStorage.setItem('nis2_impersonate_target_email', email);
// Sostituisce token con quello impersonate (no refresh per default)
api.setTokens(res.data.access_token, '');
window.location.href = '../dashboard.html';
} else {
showNotification(res.message || 'Impersonate fallito.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
</script>
</body>
</html>
-724
View File
@@ -1,724 +0,0 @@
<!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 (BI) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (wizard/form/gauge) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260613b">
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar"></aside>
<!-- Main Content -->
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="assessment.title">Gap Analysis NIS2</h1>
<div class="content-header-actions">
<button class="btn btn-outline btn-sm" id="btn-ai-analyze" style="display:none;" onclick="aiAnalyze()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
Analisi AI
</button>
</div>
</header>
<div class="content-body">
<!-- Assessment Selection / Create -->
<div id="assessment-start" class="card mb-24">
<div class="card-body text-center" style="padding:48px;">
<h3 style="font-size:1.25rem; color:var(--gray-900); margin-bottom:8px;">Assessment di Conformita' NIS2</h3>
<p class="text-muted mb-24">Valuta il livello di conformita' della tua organizzazione rispetto ai requisiti della direttiva NIS2.</p>
<div id="existing-assessments" class="mb-24" style="display:none;"></div>
<button class="btn btn-primary btn-lg" id="btn-new-assessment" onclick="createNewAssessment()">
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Nuovo Assessment
</button>
</div>
</div>
<!-- Wizard Container -->
<div id="assessment-wizard" style="display:none;">
<!-- Steps Indicator -->
<div class="steps" id="steps-indicator"></div>
<!-- Progress Bar -->
<div style="margin-bottom:16px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<span style="font-size:0.78rem; font-weight:600; color:var(--gray-600);">Avanzamento risposta domande</span>
<span style="font-size:0.78rem; font-weight:700; color:var(--primary);" id="progress-pct">0%</span>
</div>
<div style="background:var(--gray-200); border-radius:99px; height:8px; overflow:hidden;">
<div id="progress-bar-fill" style="height:100%; background:var(--primary); border-radius:99px; transition:width 0.3s ease; width:0%;"></div>
</div>
<div style="font-size:0.7rem; color:var(--gray-400); margin-top:4px;">
<span id="progress-answered">0</span> di <span id="progress-total">0</span> domande con risposta
</div>
</div>
<!-- Question Card -->
<div class="card mb-24">
<div class="card-header">
<h3 id="category-title">Caricamento...</h3>
<span class="badge badge-neutral" id="question-counter">0/0</span>
</div>
<div class="card-body" id="question-area">
<div class="spinner-lg" style="margin:40px auto;"></div>
</div>
<div class="card-footer">
<div style="display:flex; justify-content:space-between; align-items:center;">
<button class="btn btn-secondary" id="btn-prev" onclick="prevQuestion()" disabled>
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
Precedente
</button>
<button class="btn btn-ghost btn-sm" onclick="saveCurrentResponse()">
Salva Progresso
</button>
<button class="btn btn-primary" id="btn-next" onclick="nextQuestion()">
Successiva
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</button>
</div>
</div>
</div>
</div>
<!-- Results Container -->
<div id="assessment-results" style="display:none;">
<div class="card mb-24">
<div class="card-header">
<h3>Risultati Assessment</h3>
<button class="btn btn-outline btn-sm" onclick="aiAnalyze()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
Analisi AI
</button>
</div>
<div class="card-body">
<!-- Overall Score -->
<div class="text-center mb-32" id="results-gauge"></div>
<!-- Category Scores -->
<h4 style="margin-bottom:16px; color:var(--gray-900);">Punteggio per Categoria</h4>
<div id="category-scores"></div>
</div>
</div>
<!-- AI Analysis result -->
<div class="card mb-24" id="ai-analysis-card" style="display:none;">
<div class="card-header">
<h3>Analisi AI</h3>
<span class="badge badge-info">Intelligenza Artificiale</span>
</div>
<div class="card-body" id="ai-analysis-content"></div>
</div>
<!-- Generate NCR from gaps -->
<div class="card mb-24" id="ncr-generation-card">
<div class="card-header">
<h3>Non Conformita'</h3>
</div>
<div class="card-body" style="text-align:center; padding:32px;">
<p style="color:var(--gray-600); margin-bottom:16px; font-size:0.875rem;">
Genera automaticamente le non conformita' (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 btn-primary" id="btn-generate-ncr" onclick="generateNCRs()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/></svg>
Genera Non Conformita' dai Gap
</button>
<div id="ncr-generation-result" style="display:none; margin-top:16px;"></div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260613"></script>
<script src="/js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260615"></script>
<script src="/js/i18n.js?v=20260615"></script>
<script src="/js/help.js?v=20260615"></script>
<script>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
HelpSystem.init();
// ── State ────────────────────────────────────────────────
let currentAssessmentId = null;
let questions = [];
let categories = [];
let currentCategoryIdx = 0;
let currentQuestionIdx = 0;
let responses = {}; // questionId -> { answer, maturity, notes, evidence }
// ── Init ─────────────────────────────────────────────────
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-bottom:12px; color:var(--gray-700);">Assessment Esistenti</h4>';
html += '<div class="table-container"><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 btn-sm btn-ghost" onclick="viewResults('${a.id}')">Risultati</button>`
: `<button class="btn btn-sm btn-primary" onclick="resumeAssessment('${a.id}')">Riprendi</button>`
}
</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
} catch (e) {
// Silenzioso
}
}
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 = `<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg> Nuovo Assessment`;
}
}
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 style="font-size:1rem; font-weight:600; color:var(--gray-900); margin-bottom:4px;">
${escapeHtml(q.question_text || q.text || q.title || '')}
</p>
${q.guidance_it ? `<p class="text-muted" style="font-size:0.8125rem;">${escapeHtml(q.guidance_it)}</p>` : ''}
${q.nis2_article ? `<span class="tag mt-8">Art. ${escapeHtml(q.nis2_article)}</span>` : ''}
${q.iso27001_control ? `<span class="tag mt-8" style="margin-left:4px;">ISO ${escapeHtml(q.iso27001_control)}</span>` : ''}
</div>
<div class="form-group">
<label class="form-label">Livello di Conformita'</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 Maturita' (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">
<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 = 'Completa Assessment';
nextBtn.className = 'btn btn-success';
} else {
nextBtn.innerHTML = 'Successiva <svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>';
nextBtn.className = 'btn btn-primary';
}
}
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 conformita\' 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 = '';
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 style="margin-bottom:20px;"><h4 style="margin-bottom:8px;">Riepilogo</h4><p style="color:var(--gray-600); line-height:1.7;">${escapeHtml(analysis.summary)}</p></div>`;
}
if (analysis.strengths && analysis.strengths.length > 0) {
html += `<div style="margin-bottom:20px;"><h4 style="margin-bottom:8px; color:var(--secondary);">Punti di Forza</h4><ul style="padding-left:20px; color:var(--gray-600);">`;
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 style="margin-bottom:20px;"><h4 style="margin-bottom:8px; color:var(--danger);">Aree di Miglioramento</h4><ul style="padding-left:20px; color:var(--gray-600);">`;
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><h4 style="margin-bottom:8px; color:var(--primary);">Raccomandazioni</h4><ol style="padding-left:20px; color:var(--gray-600);">`;
analysis.recommendations.forEach(r => { html += `<li style="margin-bottom:8px;">${escapeHtml(r)}</li>`; });
html += '</ol></div>';
}
if (!html) {
html = `<p style="color:var(--gray-600); 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 = '<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0;"></div> 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:var(--secondary-bg); border:1px solid #bbf7d0; border-radius:var(--border-radius); padding:16px; text-align:left;">
<strong style="color:#15803d;">${total} non conformita' generate con successo</strong>
<div style="margin-top:8px; font-size:0.8125rem; color:var(--gray-600);">
${majorCount > 0 ? `<span style="color:var(--danger); font-weight:600;">${majorCount} major</span> ` : ''}
${minorCount > 0 ? `<span style="color:var(--warning); font-weight:600;">${minorCount} minor</span>` : ''}
</div>
</div>`;
showNotification(`${total} non conformita' generate.`, 'success');
} else {
resultDiv.innerHTML = '<p style="color:var(--gray-500);">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(--danger);">${escapeHtml(result.message || 'Errore nella generazione.')}</p>`;
showNotification(result.message || 'Errore.', 'error');
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/></svg> Genera Non Conformita\' dai Gap';
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/></svg> Genera Non Conformita\' dai Gap';
}
}
</script>
</body>
</html>
-956
View File
@@ -1,956 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Inventario - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (tabelle/modali/form) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260613b">
<style>
/* ── Stats Cards ────────────────────────────────────────── */
.asset-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.asset-stat-card {
background: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
}
.asset-stat-icon {
width: 48px;
height: 48px;
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.asset-stat-icon svg { width: 24px; height: 24px; }
.asset-stat-icon.primary { background: var(--primary-bg); color: var(--primary); }
.asset-stat-icon.success { background: var(--secondary-bg); color: var(--secondary); }
.asset-stat-icon.danger { background: var(--danger-bg); color: var(--danger); }
.asset-stat-icon.warning { background: var(--warning-bg); color: var(--gray-600); }
.asset-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--gray-800);
line-height: 1.2;
}
.asset-stat-label {
font-size: 0.8rem;
color: var(--gray-500);
}
/* ── Filters Bar ────────────────────────────────────────── */
.filters-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
align-items: center;
}
.filters-bar select {
padding: 8px 12px;
border: 1px solid var(--gray-200);
border-radius: var(--border-radius-sm);
background: var(--card-bg);
color: var(--gray-700);
font-size: 0.85rem;
cursor: pointer;
}
.filters-bar select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-bg);
}
/* ── Data Table ─────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
background: var(--card-bg);
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: var(--card-shadow);
}
.data-table thead {
background: var(--gray-50);
}
.data-table th {
text-align: left;
padding: 12px 16px;
font-size: 0.8rem;
font-weight: 600;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--gray-200);
}
.data-table td {
padding: 12px 16px;
font-size: 0.875rem;
color: var(--gray-700);
border-bottom: 1px solid var(--gray-100);
}
.data-table tr:last-child td { border-bottom: none; }
.data-table tbody tr { cursor: pointer; transition: background var(--transition-fast); }
.data-table tbody tr:hover td { background: var(--gray-50); }
/* ── Criticality Badges ─────────────────────────────────── */
.criticality-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 500;
}
.criticality-badge.critical { background: var(--danger-bg); color: var(--danger); }
.criticality-badge.high { background: #fff3e0; color: #e65100; }
.criticality-badge.medium { background: var(--warning-bg); color: #b8860b; }
.criticality-badge.low { background: var(--secondary-bg); color: var(--secondary); }
/* ── Status Badge ───────────────────────────────────────── */
.status-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 500;
}
.status-badge.active { background: var(--secondary-bg); color: var(--secondary); }
.status-badge.maintenance { background: var(--warning-bg); color: #b8860b; }
.status-badge.decommissioned { background: var(--gray-100); color: var(--gray-500); }
/* ── Asset Detail Panel ─────────────────────────────────── */
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.detail-field {
margin-bottom: 4px;
}
.detail-field-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.detail-field-value {
font-size: 0.9rem;
color: var(--gray-800);
}
.detail-section-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--gray-700);
margin: 20px 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--gray-200);
}
.dep-tag {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--primary-bg);
color: var(--primary);
padding: 4px 10px;
border-radius: 20px;
font-size: 0.8rem;
margin: 2px 4px 2px 0;
}
/* ── Action Buttons ─────────────────────────────────────── */
.btn-sm {
padding: 5px 12px;
font-size: 0.8rem;
}
.btn-icon {
padding: 6px;
background: none;
border: none;
color: var(--gray-400);
cursor: pointer;
border-radius: var(--border-radius-sm);
transition: all var(--transition-fast);
}
.btn-icon:hover { color: var(--primary); background: var(--primary-bg); }
.btn-icon svg { width: 18px; height: 18px; }
/* ── Type breakdown chips ───────────────────────────────── */
.type-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.type-chip {
background: var(--gray-100);
padding: 3px 10px;
border-radius: 20px;
font-size: 0.75rem;
color: var(--gray-600);
}
.type-chip strong {
color: var(--gray-800);
margin-right: 3px;
}
/* ── Loading/Empty ──────────────────────────────────────── */
.loading-state {
text-align: center;
padding: 48px 20px;
color: var(--gray-400);
}
.loading-state .spinner {
width: 32px;
height: 32px;
border: 3px solid var(--gray-200);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state-box {
text-align: center;
padding: 48px 20px;
color: var(--gray-400);
}
.empty-state-box svg {
width: 48px;
height: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.empty-state-box h4 {
color: var(--gray-600);
margin-bottom: 4px;
}
/* ── Modal form enhancements ───────────────────────────── */
.form-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.asset-stats { grid-template-columns: 1fr 1fr; }
.detail-grid { grid-template-columns: 1fr; }
.filters-bar { flex-direction: column; }
.form-grid-2 { grid-template-columns: 1fr; }
}
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="assets.title">Inventario</h1>
<div class="content-header-actions">
<span class="text-muted" style="font-size:0.8rem; margin-right:8px;">Art. 21.2.i NIS2</span>
<button class="btn btn-secondary" onclick="openImportModal()" title="Importa beni da CSV/CMDB">Importa</button>
<button class="btn btn-primary" onclick="showCreateAssetModal()">+ Nuovo bene</button>
</div>
</header>
<div class="content-body">
<!-- Stats Cards -->
<div class="asset-stats" id="asset-stats">
<div class="asset-stat-card">
<div class="asset-stat-icon primary">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clip-rule="evenodd"/></svg>
</div>
<div>
<div class="asset-stat-value" id="stat-total">--</div>
<div class="asset-stat-label">Totale beni</div>
</div>
</div>
<div class="asset-stat-card">
<div class="asset-stat-icon success">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
</div>
<div>
<div class="asset-stat-value" id="stat-types">--</div>
<div class="asset-stat-label">Tipologie</div>
<div class="type-chips" id="stat-type-chips"></div>
</div>
</div>
<div class="asset-stat-card">
<div class="asset-stat-icon danger">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
</div>
<div>
<div class="asset-stat-value" id="stat-critical">--</div>
<div class="asset-stat-label">Beni critici</div>
</div>
</div>
<div class="asset-stat-card">
<div class="asset-stat-icon warning">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>
</div>
<div>
<div class="asset-stat-value" id="stat-decommissioned">--</div>
<div class="asset-stat-label">Dismessi</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="filters-bar">
<select id="filter-type" onchange="applyFilters()" aria-label="Filtra per tipo">
<option value="">Tutti i tipi</option>
<option value="hardware">Hardware</option>
<option value="software">Software</option>
<option value="network">Rete</option>
<option value="data">Dati</option>
<option value="service">Servizio</option>
<option value="personnel">Personale</option>
<option value="facility">Struttura</option>
</select>
<select id="filter-criticality" onchange="applyFilters()" aria-label="Filtra per criticità">
<option value="">Tutte le criticita'</option>
<option value="critical">Critica</option>
<option value="high">Alta</option>
<option value="medium">Media</option>
<option value="low">Bassa</option>
</select>
<select id="filter-status" onchange="applyFilters()" aria-label="Filtra per stato">
<option value="">Tutti gli stati</option>
<option value="active">Attivo</option>
<option value="maintenance">Manutenzione</option>
<option value="decommissioned">Dismesso</option>
</select>
</div>
<!-- Asset Table -->
<div id="assets-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento inventario...</p>
</div>
</div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260613"></script>
<script src="/js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260615"></script>
<script src="/js/i18n.js?v=20260615"></script>
<script src="/js/help.js?v=20260615"></script>
<script>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
HelpSystem.init();
// ── Labels ──────────────────────────────────────────────
const typeLabels = {
hardware: 'Hardware',
software: 'Software',
network: 'Rete',
data: 'Dati',
service: 'Servizio',
personnel: 'Personale',
facility: 'Struttura'
};
const criticalityLabels = {
critical: 'Critica',
high: 'Alta',
medium: 'Media',
low: 'Bassa'
};
const statusLabels = {
active: 'Attivo',
maintenance: 'Manutenzione',
decommissioned: 'Dismesso'
};
// ── State ───────────────────────────────────────────────
let allAssets = [];
// ── Load Assets ─────────────────────────────────────────
async function loadAssets() {
const container = document.getElementById('assets-container');
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento inventario...</p></div>';
const params = {};
const type = document.getElementById('filter-type').value;
const criticality = document.getElementById('filter-criticality').value;
const status = document.getElementById('filter-status').value;
if (type) params.asset_type = type;
if (criticality) params.criticality = criticality;
if (status) params.status = status;
try {
const result = await api.listAssets(params);
if (result.success) {
allAssets = result.data || [];
updateStats(allAssets);
renderAssets(allAssets);
} else {
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4><p>' + escapeHtml(result.message || '') + '</p></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4><p>Impossibile caricare l'inventario.</p></div>';
}
}
function applyFilters() {
loadAssets();
}
function updateStats(assets) {
// For stats, we count from the current filtered results
// In a full implementation, stats would come from the server unfiltered
document.getElementById('stat-total').textContent = assets.length;
// Type breakdown
const typeCount = {};
let criticalCount = 0;
let decommCount = 0;
assets.forEach(a => {
const t = a.asset_type || 'unknown';
typeCount[t] = (typeCount[t] || 0) + 1;
if (a.criticality === 'critical') criticalCount++;
if (a.status === 'decommissioned') decommCount++;
});
const uniqueTypes = Object.keys(typeCount).length;
document.getElementById('stat-types').textContent = uniqueTypes;
document.getElementById('stat-critical').textContent = criticalCount;
document.getElementById('stat-decommissioned').textContent = decommCount;
// Type chips
const chipsEl = document.getElementById('stat-type-chips');
chipsEl.innerHTML = Object.entries(typeCount).map(([type, count]) =>
`<span class="type-chip"><strong>${count}</strong> ${typeLabels[type] || type}</span>`
).join('');
}
function renderAssets(assets) {
const container = document.getElementById('assets-container');
if (!assets || assets.length === 0) {
container.innerHTML = `
<div class="empty-state-box">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clip-rule="evenodd"/></svg>
<h4>Nessun bene trovato</h4>
<p>Registra il primo bene per costruire l'inventario.</p>
</div>`;
return;
}
let html = `
<table class="data-table">
<thead>
<tr>
<th>Nome</th>
<th>Tipo</th>
<th>Categoria</th>
<th>Criticita'</th>
<th>Rilevanza NIS2</th>
<th>Owner</th>
<th>Stato</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>`;
assets.forEach(asset => {
const crit = asset.criticality || 'medium';
const st = asset.status || 'active';
html += `
<tr onclick="showAssetDetail(${asset.id})" title="Visualizza dettagli">
<td style="font-weight:500;">${escapeHtml(asset.name)}</td>
<td>${typeLabels[asset.asset_type] || asset.asset_type || '-'}</td>
<td>${escapeHtml(asset.category || '-')}</td>
<td><span class="criticality-badge ${crit}">${criticalityLabels[crit] || crit}</span></td>
<td>${relevanceBadge(asset)}</td>
<td>${escapeHtml(asset.owner_name || '-')}</td>
<td><span class="status-badge ${st}">${statusLabels[st] || st}</span></td>
<td style="white-space:nowrap;">
<button class="btn-icon" onclick="event.stopPropagation(); showScoringModal(${asset.id})" title="Valuta rilevanza NIS2">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 3a1 1 0 011 1v12h12a1 1 0 110 2H3a1 1 0 01-1-1V4a1 1 0 011-1zm14.707 4.707a1 1 0 00-1.414-1.414L12 10.586 9.707 8.293a1 1 0 00-1.414 0L4.586 12 6 13.414l2.293-2.293L10.586 13l5.121-5.293z" clip-rule="evenodd"/></svg>
</button>
<button class="btn-icon" onclick="event.stopPropagation(); showEditAssetModal(${asset.id})" title="Modifica">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
</button>
</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
// ── Rilevanza NIS2 (GV.OC-04) ───────────────────────────
const relevanceClassColors = {
critico: '#dc2626', alto: '#ea580c', medio: '#ca8a04',
basso: '#2563eb', trascurabile: '#6b7280'
};
function relevanceBadge(asset) {
if (asset.relevance_score === null || asset.relevance_score === undefined || asset.relevance_score === '') {
return '<span style="color:var(--gray-400); font-size:0.8rem;">Da valutare</span>';
}
const cls = asset.relevance_class || 'trascurabile';
const color = relevanceClassColors[cls] || '#6b7280';
const rel = Number(asset.is_nis2_relevant) ? ' ✓' : '';
return `<span style="display:inline-flex;align-items:center;gap:6px;font-size:0.8rem;font-weight:600;color:${color};">
<span style="display:inline-block;min-width:30px;text-align:center;padding:2px 6px;border-radius:6px;background:${color}1a;">${asset.relevance_score}</span>
${cls.charAt(0).toUpperCase() + cls.slice(1)}${rel}</span>`;
}
let _scoringGrid = null;
async function loadScoringGrid() {
if (_scoringGrid) return _scoringGrid;
const r = await api.getScoringGrid();
if (r.success) _scoringGrid = r.data;
return _scoringGrid;
}
async function showScoringModal(id) {
try {
const [assetRes, grid] = await Promise.all([api.getAsset(id), loadScoringGrid()]);
if (!assetRes.success || !grid) { showNotification('Errore caricamento dati.', 'error'); return; }
const a = assetRes.data;
let prev = a.relevance_criteria;
if (typeof prev === 'string') { try { prev = JSON.parse(prev); } catch (e) { prev = null; } }
let body = `<p style="font-size:0.85rem;color:var(--gray-600);margin-bottom:1rem;">
Metodologia di scoring rilevanza NIS2 (requisito <strong>GV.OC-04</strong>). Soglia rilevanza: <strong>&ge;${grid.threshold} punti</strong>.
Il punteggio aggiorna automaticamente anche la criticita del bene.</p>`;
for (const [key, def] of Object.entries(grid.grid)) {
const sel = prev && prev[key] ? prev[key].value : '';
let opts = `<option value="">— seleziona —</option>`;
for (const [ov, od] of Object.entries(def.options)) {
opts += `<option value="${ov}" data-pts="${od.points}" ${ov === sel ? 'selected' : ''}>${od.label} (${od.points})</option>`;
}
body += `<div class="form-group" style="margin-bottom:0.75rem;">
<label class="form-label" style="font-weight:600;">${def.label} <span style="color:var(--gray-400);font-weight:400;">(max ${def.max})</span></label>
<div style="font-size:0.78rem;color:var(--gray-500);margin-bottom:4px;">${def.help}</div>
<select class="form-select score-criterion" data-key="${key}" onchange="updateScorePreview()" aria-label="${def.label}">${opts}</select>
</div>`;
}
body += `<div id="score-preview" style="margin-top:1rem;padding:0.9rem;border-radius:10px;background:var(--gray-50);font-weight:600;text-align:center;">
Totale: <span id="score-total">0</span>/100 — <span id="score-class">—</span></div>`;
showModal(`Valuta Rilevanza NIS2 — ${escapeHtml(a.name)}`, body, {
size: 'lg',
footer: `<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="submitScoring(${id})">Calcola e Salva</button>`
});
updateScorePreview();
} catch (e) {
showNotification('Errore nell\'apertura della valutazione.', 'error');
}
}
function updateScorePreview() {
let total = 0, complete = true;
document.querySelectorAll('.score-criterion').forEach(s => {
if (!s.value) { complete = false; return; }
total += parseInt(s.selectedOptions[0].dataset.pts || '0', 10);
});
let cls = total >= 80 ? 'critico' : total >= 60 ? 'alto' : total >= 40 ? 'medio' : total >= 20 ? 'basso' : 'trascurabile';
const color = relevanceClassColors[cls];
document.getElementById('score-total').textContent = total;
const clsEl = document.getElementById('score-class');
clsEl.textContent = complete ? `${cls.charAt(0).toUpperCase() + cls.slice(1)}${total >= 40 ? ' — Rilevante NIS2 ✓' : ''}` : '(completa tutti i criteri)';
clsEl.style.color = complete ? color : 'var(--gray-400)';
}
async function submitScoring(id) {
const criteria = {};
let complete = true;
document.querySelectorAll('.score-criterion').forEach(s => {
if (!s.value) complete = false;
criteria[s.dataset.key] = s.value;
});
if (!complete) { showNotification('Compila tutti i 6 criteri.', 'warning'); return; }
try {
const r = await api.scoreAsset(id, criteria);
if (r.success) {
showNotification(`Rilevanza calcolata: ${r.data.score}/100 (${r.data.class}).`, 'success');
closeModal();
loadAssets();
} else {
showNotification(r.message || 'Errore nel calcolo.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Asset Detail View ───────────────────────────────────
async function showAssetDetail(id) {
try {
const result = await api.getAsset(id);
if (!result.success || !result.data) {
showNotification('Bene non trovato.', 'error');
return;
}
const a = result.data;
const crit = a.criticality || 'medium';
const st = a.status || 'active';
let depsHtml = '<span style="color:var(--gray-400);">Nessuna dipendenza</span>';
if (a.dependencies) {
let deps = a.dependencies;
if (typeof deps === 'string') {
try { deps = JSON.parse(deps); } catch(e) { deps = []; }
}
if (deps && deps.length > 0) {
depsHtml = deps.map(d => `<span class="dep-tag">ID: ${escapeHtml(String(d))}</span>`).join('');
}
}
showModal(escapeHtml(a.name), `
<div class="detail-grid">
<div class="detail-field">
<div class="detail-field-label">Tipo</div>
<div class="detail-field-value">${typeLabels[a.asset_type] || a.asset_type || '-'}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Categoria</div>
<div class="detail-field-value">${escapeHtml(a.category || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Criticita'</div>
<div class="detail-field-value"><span class="criticality-badge ${crit}">${criticalityLabels[crit] || crit}</span></div>
</div>
<div class="detail-field">
<div class="detail-field-label">Stato</div>
<div class="detail-field-value"><span class="status-badge ${st}">${statusLabels[st] || st}</span></div>
</div>
<div class="detail-field">
<div class="detail-field-label">Owner</div>
<div class="detail-field-value">${escapeHtml(a.owner_name || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Posizione</div>
<div class="detail-field-value">${escapeHtml(a.location || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Indirizzo IP</div>
<div class="detail-field-value">${escapeHtml(a.ip_address || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Fornitore</div>
<div class="detail-field-value">${escapeHtml(a.vendor || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Versione</div>
<div class="detail-field-value">${escapeHtml(a.version || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Numero Seriale</div>
<div class="detail-field-value">${escapeHtml(a.serial_number || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Data Acquisto</div>
<div class="detail-field-value">${formatDate(a.purchase_date)}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Scadenza Garanzia</div>
<div class="detail-field-value">${formatDate(a.warranty_expiry)}</div>
</div>
</div>
${a.description ? `
<div class="detail-section-title">Descrizione</div>
<p style="font-size:0.9rem; color:var(--gray-600);">${escapeHtml(a.description)}</p>
` : ''}
<div class="detail-section-title">Dipendenze</div>
<div>${depsHtml}</div>
`, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>
<button class="btn btn-secondary" onclick="closeModal(); showScoringModal(${a.id})">Valuta Rilevanza NIS2</button>
<button class="btn btn-primary" onclick="closeModal(); showEditAssetModal(${a.id})">Modifica</button>
`
});
} catch (e) {
showNotification('Errore nel caricamento del bene.', 'error');
}
}
// ── Create Asset Modal ──────────────────────────────────
function showCreateAssetModal() {
showAssetModal('Nuovo bene', {}, async function(data) {
try {
const result = await api.createAsset(data);
if (result.success) {
showNotification('Bene registrato con successo!', 'success');
closeModal();
loadAssets();
} else {
showNotification(result.message || 'Errore nella creazione.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
});
}
// ── Edit Asset Modal ────────────────────────────────────
async function showEditAssetModal(id) {
try {
const result = await api.getAsset(id);
if (!result.success || !result.data) {
showNotification('Bene non trovato.', 'error');
return;
}
showAssetModal('Modifica bene', result.data, async function(data) {
try {
const res = await api.updateAsset(id, data);
if (res.success) {
showNotification('Bene aggiornato con successo!', 'success');
closeModal();
loadAssets();
} else {
showNotification(res.message || 'Errore nell\'aggiornamento.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
});
} catch (e) {
showNotification('Errore nel caricamento.', 'error');
}
}
// ── Generic Asset Modal ─────────────────────────────────
function showAssetModal(title, asset, onSave) {
const a = asset || {};
const typeOptions = Object.entries(typeLabels).map(([val, label]) =>
`<option value="${val}" ${a.asset_type === val ? 'selected' : ''}>${label}</option>`
).join('');
const critOptions = Object.entries(criticalityLabels).map(([val, label]) =>
`<option value="${val}" ${a.criticality === val ? 'selected' : ''}>${label}</option>`
).join('');
const statusOptions = Object.entries(statusLabels).map(([val, label]) =>
`<option value="${val}" ${a.status === val ? 'selected' : ''}>${label}</option>`
).join('');
// Store the callback
window._assetSaveCallback = onSave;
showModal(title, `
<div class="form-group">
<label for="asset-name" class="form-label">Nome *</label>
<input type="text" class="form-input" id="asset-name" value="${escapeHtml(a.name || '')}" required>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="asset-type" class="form-label">Tipo *</label>
<select class="form-select" id="asset-type">${typeOptions}</select>
</div>
<div class="form-group">
<label for="asset-category" class="form-label">Categoria</label>
<input type="text" class="form-input" id="asset-category" value="${escapeHtml(a.category || '')}" placeholder="Es: Server, Database...">
</div>
</div>
<div class="form-group">
<label for="asset-description" class="form-label">Descrizione</label>
<textarea class="form-input" id="asset-description" rows="2">${escapeHtml(a.description || '')}</textarea>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="asset-criticality" class="form-label">Criticita'</label>
<select class="form-select" id="asset-criticality">${critOptions}</select>
</div>
<div class="form-group">
<label for="asset-status" class="form-label">Stato</label>
<select class="form-select" id="asset-status">${statusOptions}</select>
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="asset-owner" class="form-label">Owner (ID utente)</label>
<input type="text" class="form-input" id="asset-owner" value="${escapeHtml(a.owner_user_id || '')}">
</div>
<div class="form-group">
<label for="asset-location" class="form-label">Posizione</label>
<input type="text" class="form-input" id="asset-location" value="${escapeHtml(a.location || '')}" placeholder="Es: Data Center Roma">
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="asset-ip" class="form-label">Indirizzo IP</label>
<input type="text" class="form-input" id="asset-ip" value="${escapeHtml(a.ip_address || '')}" placeholder="Es: 192.168.1.100">
</div>
<div class="form-group">
<label for="asset-vendor" class="form-label">Fornitore</label>
<input type="text" class="form-input" id="asset-vendor" value="${escapeHtml(a.vendor || '')}">
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="asset-version" class="form-label">Versione</label>
<input type="text" class="form-input" id="asset-version" value="${escapeHtml(a.version || '')}">
</div>
<div class="form-group">
<label for="asset-serial" class="form-label">Numero Seriale</label>
<input type="text" class="form-input" id="asset-serial" value="${escapeHtml(a.serial_number || '')}">
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="asset-purchase-date" class="form-label">Data Acquisto</label>
<input type="date" class="form-input" id="asset-purchase-date" value="${a.purchase_date || ''}">
</div>
<div class="form-group">
<label for="asset-warranty" class="form-label">Scadenza Garanzia</label>
<input type="date" class="form-input" id="asset-warranty" value="${a.warranty_expiry || ''}">
</div>
</div>
`, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="saveAsset()">Salva</button>
`
});
}
function saveAsset() {
const name = document.getElementById('asset-name').value.trim();
if (!name) {
showNotification('Il nome e\' obbligatorio.', 'warning');
return;
}
const data = {
name: name,
asset_type: document.getElementById('asset-type').value,
category: document.getElementById('asset-category').value.trim() || null,
description: document.getElementById('asset-description').value.trim() || null,
criticality: document.getElementById('asset-criticality').value,
owner_user_id: document.getElementById('asset-owner').value.trim() || null,
location: document.getElementById('asset-location').value.trim() || null,
ip_address: document.getElementById('asset-ip').value.trim() || null,
vendor: document.getElementById('asset-vendor').value.trim() || null,
version: document.getElementById('asset-version').value.trim() || null,
serial_number: document.getElementById('asset-serial').value.trim() || null,
purchase_date: document.getElementById('asset-purchase-date').value || null,
warranty_expiry: document.getElementById('asset-warranty').value || null,
status: document.getElementById('asset-status').value,
};
if (window._assetSaveCallback) {
window._assetSaveCallback(data);
}
}
// ── Initial Load ────────────────────────────────────────
loadAssets();
/* ── Import asset CMDB/CSV (P2) ───────────────────────────── */
function openImportModal(){
const body = `
<p style="font-size:.85rem;color:var(--gray-600);margin-bottom:10px;">Incolla CSV o carica un file. Colonne riconosciute:
<code>name, asset_type, criticality, data_classification, internet_facing, dependencies_count, regulated, external_ref, vendor, location</code>.
Lo scoring di rilevanza NIS2 (GV.OC-04) viene calcolato automaticamente.</p>
<div class="form-group"><input type="file" accept=".csv,text/csv" id="imp-file" onchange="impFile(event)" aria-label="Seleziona file CSV da importare"></div>
<div class="form-group"><label for="imp-csv" class="form-label">Oppure incolla CSV (prima riga = intestazioni)</label>
<textarea class="form-input" id="imp-csv" rows="8" placeholder="name,asset_type,criticality,data_classification,internet_facing,dependencies_count,regulated
Core ERP,software,critical,financial,false,8,true
Web Portal,service,high,personal,true,3,true"></textarea></div>
<div id="imp-preview" style="font-size:.8rem;color:var(--gray-600);"></div>`;
showModal('Importa beni', body, {
size:'lg',
footer:`<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="doImport()">Importa</button>`
});
}
function impFile(e){
const f=e.target.files[0]; if(!f)return;
const r=new FileReader(); r.onload=()=>{document.getElementById('imp-csv').value=r.result;}; r.readAsText(f);
}
function parseCsv(text){
const lines=text.trim().split(/\r?\n/).filter(l=>l.trim());
if(lines.length<2)return [];
const headers=lines[0].split(',').map(h=>h.trim().toLowerCase());
const truthy=v=>/^(1|true|si|sì|yes|y)$/i.test((v||'').trim());
return lines.slice(1).map(line=>{
const cells=line.split(','); const o={};
headers.forEach((h,i)=>{ o[h]=(cells[i]||'').trim(); });
const a={name:o.name, asset_type:o.asset_type, criticality:o.criticality,
data_classification:o.data_classification, vendor:o.vendor, location:o.location,
external_ref:o.external_ref};
if('internet_facing' in o) a.internet_facing=truthy(o.internet_facing);
if('regulated' in o) a.regulated=truthy(o.regulated);
if('dependencies_count' in o) a.dependencies_count=parseInt(o.dependencies_count)||0;
return a;
}).filter(a=>a.name);
}
async function doImport(){
const assets=parseCsv(document.getElementById('imp-csv').value);
if(!assets.length){showNotification('Nessuna riga valida (serve almeno name)','warning');return;}
try{
const res=await api.importAssets({source:'csv', assets});
if(res.success){
const d=res.data;
closeModal();
showNotification(`Import: ${d.imported} nuovi, ${d.updated} aggiornati, ${d.relevant} rilevanti NIS2, ${d.skipped} scartati`,'success');
if(typeof loadAssets==='function')loadAssets();
} else showNotification(res.message||'Errore import','error');
}catch(e){showNotification('Errore di connessione','error');}
}
</script>
</body>
</html>
-659
View File
@@ -1,659 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Le Mie Aziende - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (MAI CDN). Caricato PRIMA di style.css: BI veste la chrome/sidebar, style.css resta autorevole per i widget. -->
<link rel="stylesheet" href="vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="css/style.css?v=20260613b">
<style>
.companies-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
flex-wrap: wrap;
}
.companies-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--gray-900);
letter-spacing: -0.01em;
}
.companies-header-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search-box {
position: relative;
min-width: 240px;
}
.search-box svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--gray-400);
pointer-events: none;
}
.search-box input {
padding-left: 38px;
}
/* ── Company Cards Grid ─────────────────────────────────────── */
.companies-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.company-card {
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius-lg);
padding: 24px;
transition: all var(--transition);
display: flex;
flex-direction: column;
gap: 16px;
}
.company-card:hover {
box-shadow: var(--card-shadow-hover);
border-color: var(--primary-light);
transform: translateY(-2px);
}
.company-card-header {
display: flex;
align-items: flex-start;
gap: 14px;
}
.company-card-avatar {
width: 48px;
height: 48px;
border-radius: var(--border-radius);
background: var(--primary-bg);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.1rem;
flex-shrink: 0;
}
.company-card-info {
flex: 1;
min-width: 0;
}
.company-card-name {
font-size: 1rem;
font-weight: 700;
color: var(--gray-900);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.company-card-vat {
font-size: 0.8125rem;
color: var(--gray-500);
font-family: monospace;
}
.entity-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 99px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.entity-badge.essential {
background: rgba(234, 67, 53, 0.1);
color: #c5221f;
}
.entity-badge.important {
background: rgba(251, 188, 4, 0.15);
color: #b06000;
}
.entity-badge.not_applicable,
.entity-badge.voluntary {
background: var(--gray-100);
color: var(--gray-500);
}
/* ── Score Bar ───────────────────────────────────────────────── */
.company-score-row {
display: flex;
align-items: center;
gap: 12px;
}
.company-score-label {
font-size: 0.8125rem;
color: var(--gray-500);
white-space: nowrap;
}
.company-score-bar {
flex: 1;
height: 6px;
background: var(--gray-100);
border-radius: 99px;
overflow: hidden;
}
.company-score-bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.6s ease;
}
.company-score-value {
font-size: 0.875rem;
font-weight: 700;
min-width: 36px;
text-align: right;
}
/* ── Stats Row ───────────────────────────────────────────────── */
.company-stats {
display: flex;
gap: 16px;
}
.company-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.company-stat-value {
font-size: 1.125rem;
font-weight: 700;
color: var(--gray-900);
}
.company-stat-label {
font-size: 0.75rem;
color: var(--gray-500);
}
/* ── Card Footer ─────────────────────────────────────────────── */
.company-card-footer {
border-top: 1px solid var(--gray-100);
padding-top: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.company-card-sector {
font-size: 0.8125rem;
color: var(--gray-500);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Empty State ─────────────────────────────────────────────── */
.companies-empty {
text-align: center;
padding: 80px 24px;
color: var(--gray-500);
}
.companies-empty svg {
width: 64px;
height: 64px;
color: var(--gray-300);
margin: 0 auto 20px;
display: block;
}
.companies-empty h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 8px;
}
.companies-empty p {
font-size: 0.9375rem;
margin-bottom: 24px;
line-height: 1.6;
}
/* ── Loading skeleton ────────────────────────────────────────── */
.skeleton {
background: linear-gradient(90deg, var(--gray-100) 25%, var(--gray-50) 50%, var(--gray-100) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 6px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-card {
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius-lg);
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<!-- Sidebar -->
<aside id="sidebar" class="sidebar"></aside>
<!-- Main Content -->
<main class="main-content">
<div class="page-container">
<!-- Page Header -->
<div class="companies-header">
<div>
<h1>Le Mie Aziende</h1>
<p style="color:var(--gray-500);font-size:.9rem;margin-top:2px;">Gestisci il portafoglio clienti e monitora la compliance NIS2</p>
</div>
<div class="companies-header-actions">
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
<input type="text" class="form-input" id="search-input" placeholder="Cerca azienda..." oninput="filterCompanies(this.value)">
</div>
<a href="onboarding-bi.html" class="btn btn-primary">
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Aggiungi Cliente
</a>
</div>
</div>
<!-- Companies Grid -->
<div class="companies-grid" id="companies-grid">
<!-- Skeleton loaders -->
<div class="skeleton-card">
<div style="display:flex;gap:12px;align-items:center">
<div class="skeleton" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div>
<div style="flex:1"><div class="skeleton" style="height:16px;margin-bottom:8px"></div><div class="skeleton" style="height:12px;width:60%"></div></div>
</div>
<div class="skeleton" style="height:6px"></div>
<div style="display:flex;gap:16px"><div class="skeleton" style="height:40px;flex:1"></div><div class="skeleton" style="height:40px;flex:1"></div></div>
<div class="skeleton" style="height:36px"></div>
</div>
<div class="skeleton-card">
<div style="display:flex;gap:12px;align-items:center">
<div class="skeleton" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div>
<div style="flex:1"><div class="skeleton" style="height:16px;margin-bottom:8px"></div><div class="skeleton" style="height:12px;width:60%"></div></div>
</div>
<div class="skeleton" style="height:6px"></div>
<div style="display:flex;gap:16px"><div class="skeleton" style="height:40px;flex:1"></div><div class="skeleton" style="height:40px;flex:1"></div></div>
<div class="skeleton" style="height:36px"></div>
</div>
</div>
<!-- Empty state (hidden initially) -->
<div class="companies-empty" id="companies-empty" style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z"/>
</svg>
<h3>Nessuna azienda ancora</h3>
<p>Aggiungi il tuo primo cliente per iniziare a monitorare la compliance NIS2.</p>
<a href="onboarding-bi.html" class="btn btn-primary">
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Aggiungi Primo Cliente
</a>
</div>
</div>
</main>
</div>
<script src="js/api.js?v=20260613"></script>
<script src="js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle + override sidebar BI (common-bi.js dopo common.js: ridefinisce solo loadSidebar) -->
<script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }</script>
<script src="js/common-bi.js?v=20260615"></script>
<script src="js/i18n.js?v=20260615"></script>
<script src="js/help.js?v=20260615"></script>
<script>
if (!checkAuth()) throw new Error('Not authenticated');
// Check that user is consultant, else redirect
if (!api.isConsultant()) {
window.location.href = 'dashboard-bi.html';
throw new Error('Not consultant');
}
loadSidebar();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
// ── Data ────────────────────────────────────────────────────────
let allCompanies = [];
// ── Load companies ───────────────────────────────────────────────
async function loadCompanies() {
const result = await api.listOrganizations();
const grid = document.getElementById('companies-grid');
const emptyState = document.getElementById('companies-empty');
if (!result.success) {
showNotification('Errore nel caricamento delle aziende.', 'error');
grid.innerHTML = '';
emptyState.style.display = 'block';
return;
}
const orgs = result.data || [];
allCompanies = orgs;
if (orgs.length === 0) {
grid.innerHTML = '';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
renderCompanies(orgs);
// Load compliance scores in background
orgs.forEach(org => loadOrgScore(org.id || org.organization_id));
}
// ── Render companies ─────────────────────────────────────────────
function renderCompanies(orgs) {
const grid = document.getElementById('companies-grid');
grid.innerHTML = orgs.map(org => renderCompanyCard(org)).join('');
}
function renderCompanyCard(org) {
const orgId = org.id || org.organization_id;
const name = org.name || 'Azienda';
const vat = org.vat_number ? 'IT' + org.vat_number : '-';
const sector = org.sector || '';
const entityType = org.entity_type || 'not_applicable';
const voluntary = org.voluntary_compliance ? true : false;
const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
const entityBadge = renderEntityBadge(entityType, voluntary);
const sectorLabel = getSectorLabel(sector);
return `
<div class="company-card" data-name="${escapeHtml(name.toLowerCase())}" data-org-id="${orgId}">
<div class="company-card-header">
<div class="company-card-avatar">${escapeHtml(initials)}</div>
<div class="company-card-info">
<div class="company-card-name" title="${escapeHtml(name)}">${escapeHtml(name)}</div>
<div class="company-card-vat">${escapeHtml(vat)}</div>
</div>
${entityBadge}
</div>
<!-- Score bar (loaded async) -->
<div class="company-score-row" id="score-row-${orgId}" style="display:none">
<span class="company-score-label">Compliance</span>
<div class="company-score-bar">
<div class="company-score-bar-fill" id="score-fill-${orgId}" style="width:0%;background:#ccc"></div>
</div>
<span class="company-score-value" id="score-val-${orgId}">-</span>
</div>
<!-- Stats row (loaded async) -->
<div class="company-stats" id="stats-row-${orgId}" style="display:none">
<div class="company-stat">
<span class="company-stat-value" id="stat-risks-${orgId}">-</span>
<span class="company-stat-label">Rischi aperti</span>
</div>
<div class="company-stat">
<span class="company-stat-value" id="stat-incidents-${orgId}">-</span>
<span class="company-stat-label">Incidenti attivi</span>
</div>
</div>
<div class="company-card-footer">
<span class="company-card-sector">${escapeHtml(sectorLabel)}</span>
<button class="btn btn-ghost btn-sm" onclick="openConnectors(${orgId}, '${escapeHtml(name).replace(/'/g, "\\'")}')" title="Connettori Evidence Automation">
Connettori
</button>
<button class="btn btn-primary btn-sm" onclick="manageCompany(${orgId})">
Gestisci
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</button>
</div>
</div>`;
}
// ── Connettori per-azienda (Evidence Automation) ─────────────
const CONNECTOR_LABELS = {
m365: 'Microsoft 365 / Entra ID', google: 'Google Workspace', aws: 'Amazon Web Services',
azure: 'Microsoft Azure', idp: 'Identity Provider', edr: 'EDR / XDR',
siem: 'SIEM / SOC', ticketing: 'Ticketing (Jira/ServiceNow)'
};
let _connOrgId = null;
async function openConnectors(orgId, orgName) {
_connOrgId = orgId;
try {
const res = await fetch(`/api/organizations/${orgId}/connectors`, {
headers: { 'Authorization': `Bearer ${api.token}`, 'X-Organization-Id': orgId }
});
const data = await res.json();
if (!data.success) { showConnModal(orgName, `<p style="color:#ef4444;">${escapeHtml(data.message || 'Errore')}</p>`); return; }
const existing = {};
(data.data.connectors || []).forEach(c => existing[c.connector_type] = c);
const rows = (data.data.available_types || []).map(t => {
const c = existing[t] || {};
const cfg = (c.config && typeof c.config === 'object') ? c.config : {};
const ss = c.secret_status || 'not_set';
const ssBadge = { not_set: ['Segreto non impostato', '#9ca3af'], pending: ['Segreto in attesa', '#eab308'], configured: ['Segreto configurato', '#22c55e'] }[ss];
return `<div class="conn-row" data-type="${t}" style="border:1px solid var(--gray-200);border-radius:8px;padding:12px;margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<strong>${escapeHtml(CONNECTOR_LABELS[t] || t)}</strong>
<label style="font-size:.8rem;"><input type="checkbox" class="c-en" ${c.enabled ? 'checked' : ''}> Attivo</label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:8px;">
<input class="c-tenant" placeholder="tenant_id / account_id" value="${escapeHtml(cfg.tenant_id || cfg.account_id || '')}" style="padding:6px;border:1px solid var(--gray-300);border-radius:6px;">
<input class="c-client" placeholder="client_id / app_id" value="${escapeHtml(cfg.client_id || cfg.app_id || '')}" style="padding:6px;border:1px solid var(--gray-300);border-radius:6px;">
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<span style="font-size:.72rem;color:${ssBadge[1]};">● ${ssBadge[0]}</span>
<button class="btn btn-sm btn-primary" onclick="saveConn('${t}', this)">Salva</button>
</div>
</div>`;
}).join('');
showConnModal(orgName, `
<p style="font-size:.82rem;color:var(--gray-600);margin-bottom:12px;">
Configura i connettori di <strong>Evidence Automation</strong>. Qui si salvano solo i parametri
NON segreti (tenant/client id). Il <strong>client secret</strong> va caricato nel vault dall'amministratore
(il comando viene mostrato dopo il salvataggio).</p>${rows}`);
} catch (e) { showConnModal(orgName, '<p style="color:#ef4444;">Errore di connessione</p>'); }
}
function showConnModal(orgName, body) {
const old = document.getElementById('conn-modal'); if (old) old.remove();
document.body.insertAdjacentHTML('beforeend', `
<div id="conn-modal" onclick="if(event.target===this)closeConn()" style="position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;padding:20px;">
<div style="background:var(--card-bg,#fff);border-radius:12px;max-width:640px;width:100%;max-height:85vh;overflow-y:auto;padding:22px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;">
<h3 style="margin:0;">Connettori — ${escapeHtml(orgName)}</h3>
<button class="btn btn-ghost btn-sm" onclick="closeConn()">✕</button>
</div>${body}
</div></div>`);
}
function closeConn() { const m = document.getElementById('conn-modal'); if (m) m.remove(); }
async function saveConn(type, btn) {
const row = btn.closest('.conn-row');
const payload = {
type,
enabled: row.querySelector('.c-en').checked,
config: { tenant_id: row.querySelector('.c-tenant').value.trim(), client_id: row.querySelector('.c-client').value.trim() },
secret_status: 'pending'
};
btn.disabled = true; btn.textContent = '...';
try {
const res = await fetch(`/api/organizations/${_connOrgId}/connectors`, {
method: 'PUT',
headers: { 'Authorization': `Bearer ${api.token}`, 'X-Organization-Id': _connOrgId, 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const j = await res.json();
btn.disabled = false; btn.textContent = 'Salva';
if (j.success) {
if (typeof showNotification === 'function') showNotification('Connettore salvato', 'success');
if (j.data && j.data.cli_hint) alert('Connettore salvato.\n\nPer caricare il SEGRETO nel vault, l\'amministratore esegue:\n\n' + j.data.cli_hint);
} else if (typeof showNotification === 'function') showNotification(j.message || 'Errore', 'error');
} catch (e) {
btn.disabled = false; btn.textContent = 'Salva';
if (typeof showNotification === 'function') showNotification('Errore di connessione', 'error');
}
}
function renderEntityBadge(type, voluntary) {
if (voluntary) {
return `<span class="entity-badge voluntary">Volontaria</span>`;
}
if (type === 'essential') {
return `<span class="entity-badge essential">Essenziale</span>`;
}
if (type === 'important') {
return `<span class="entity-badge important">Importante</span>`;
}
return `<span class="entity-badge not_applicable">Non NIS2</span>`;
}
// ── Load compliance score per org ────────────────────────────────
async function loadOrgScore(orgId) {
try {
// Temporarily switch to this org for the API call
const savedOrgId = api.orgId;
api.orgId = orgId;
const result = await api.getDashboardOverview();
api.orgId = savedOrgId;
if (result.success && result.data) {
const score = result.data.compliance_score || 0;
const risks = result.data.open_risks || 0;
const incidents = result.data.active_incidents || 0;
const scoreRow = document.getElementById('score-row-' + orgId);
const scoreVal = document.getElementById('score-val-' + orgId);
const scoreFill = document.getElementById('score-fill-' + orgId);
const statsRow = document.getElementById('stats-row-' + orgId);
const statRisks = document.getElementById('stat-risks-' + orgId);
const statIncidents = document.getElementById('stat-incidents-' + orgId);
if (scoreRow) scoreRow.style.display = 'flex';
if (scoreVal) {
scoreVal.textContent = score + '%';
scoreVal.style.color = getScoreColor(score);
}
if (scoreFill) {
scoreFill.style.width = score + '%';
scoreFill.style.background = getScoreColor(score);
}
if (statsRow) statsRow.style.display = 'flex';
if (statRisks) statRisks.textContent = risks;
if (statIncidents) statIncidents.textContent = incidents;
}
} catch (e) {
// Silently ignore per-org errors
}
}
// ── Manage company (switch org + go to dashboard) ─────────────────
function manageCompany(orgId) {
api.setOrganization(orgId);
window.location.href = 'dashboard-bi.html';
}
// ── Search / Filter ──────────────────────────────────────────────
function filterCompanies(query) {
const q = query.toLowerCase().trim();
const cards = document.querySelectorAll('.company-card');
let anyVisible = false;
cards.forEach(card => {
const name = card.dataset.name || '';
const visible = !q || name.includes(q);
card.style.display = visible ? '' : 'none';
if (visible) anyVisible = true;
});
const emptyState = document.getElementById('companies-empty');
if (emptyState) {
emptyState.style.display = (!anyVisible && allCompanies.length > 0) ? 'block' : 'none';
}
}
// ── Sector labels ────────────────────────────────────────────────
function getSectorLabel(sector) {
const labels = {
energy_electricity: 'Energia - Elettricità',
energy_oil: 'Energia - Petrolio', energy_gas: 'Energia - Gas',
energy_hydrogen: 'Energia - Idrogeno',
transport_air: 'Trasporti - Aereo', transport_rail: 'Trasporti - Ferroviario',
transport_water: 'Trasporti - Marittimo', transport_road: 'Trasporti - Stradale',
banking: 'Banche', financial_markets: 'Mercati Finanziari',
health: 'Sanità', drinking_water: 'Acqua Potabile',
waste_water: 'Acque Reflue', digital_infrastructure: 'Infrastruttura Digitale',
ict_service_management: 'Gestione ICT B2B', public_administration: 'PA',
space: 'Spazio', postal_courier: 'Postale/Corrieri',
waste_management: 'Gestione Rifiuti', chemicals: 'Chimica',
food: 'Alimentare', manufacturing_medical: 'Dispositivi Medici',
manufacturing_computers: 'Computer/Elettronica',
manufacturing_electrical: 'App. Elettriche',
manufacturing_machinery: 'Macchinari', manufacturing_vehicles: 'Autoveicoli',
manufacturing_transport: 'Mezzi di Trasporto',
digital_providers: 'Fornitori Digitali', research: 'Ricerca', other: 'Altro'
};
return labels[sector] || sector || 'Settore non specificato';
}
// ── Init ─────────────────────────────────────────────────────────
loadCompanies();
</script>
</body>
</html>
-711
View File
@@ -1,711 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>AI Cross-Analysis (BI) — NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (chat/portfolio/storico) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260613b">
<style>
:root {
--nis2-cyan: #06B6D4;
--nis2-cyan-light: #ecfeff;
--l4-purple: #7c3aed;
--l4-purple-bg: #f5f3ff;
}
/* Icone sprite BI: dimensione coerente inline */
.ico { width: 1em; height: 1em; vertical-align: -0.125em; fill: currentColor; }
.ico-lg { width: 2.5rem; height: 2.5rem; }
/* ── Chat container ─────────────────────────── */
.chat-wrap {
display: flex;
flex-direction: column;
height: calc(100vh - 200px);
min-height: 500px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.chat-input-area {
border-top: 1px solid var(--gray-200);
padding: 16px;
background: var(--card-bg);
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg);
}
.chat-input-row {
display: flex;
gap: 10px;
align-items: flex-end;
}
.chat-input-row textarea {
flex: 1;
min-height: 56px;
max-height: 140px;
resize: vertical;
padding: 10px 14px;
border: 1.5px solid var(--gray-300);
border-radius: var(--border-radius);
font-family: var(--font-family);
font-size: 0.9rem;
line-height: 1.5;
transition: border-color var(--transition);
}
.chat-input-row textarea:focus {
outline: none;
border-color: var(--l4-purple);
}
.btn-send {
background: var(--l4-purple);
color: white;
border: none;
border-radius: var(--border-radius);
padding: 14px 18px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
flex-shrink: 0;
}
.btn-send:hover:not(:disabled) { background: #6d28d9; }
.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-send:focus-visible { outline: 3px solid #fff; outline-offset: 2px; box-shadow: 0 0 0 5px var(--l4-purple); }
/* ── Message bubbles ────────────────────────── */
.msg {
display: flex;
gap: 10px;
align-items: flex-start;
}
.msg.user { flex-direction: row-reverse; }
.msg-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
flex-shrink: 0;
}
.msg.user .msg-avatar { background: var(--l4-purple); color: white; }
.msg.ai .msg-avatar { background: var(--gray-800); color: white; }
.msg-body {
max-width: 78%;
flex: 1;
}
.msg.user .msg-body { display: flex; justify-content: flex-end; }
.msg-bubble {
padding: 10px 14px;
border-radius: var(--border-radius);
font-size: 0.88rem;
line-height: 1.55;
}
.msg.user .msg-bubble {
background: var(--l4-purple);
color: white;
border-radius: var(--border-radius) 0 var(--border-radius) var(--border-radius);
}
.msg.ai .msg-bubble {
background: var(--gray-100);
color: var(--gray-800);
border-radius: 0 var(--border-radius) var(--border-radius) var(--border-radius);
width: 100%;
}
/* ── AI response structured ─────────────────── */
.ai-answer { margin-bottom: 12px; font-size: 0.88rem; line-height: 1.6; }
.ai-section { margin-top: 12px; }
.ai-section-title {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--gray-500);
margin-bottom: 5px;
}
.ai-section ul {
margin: 0; padding: 0 0 0 16px;
font-size: 0.84rem;
color: var(--gray-700);
}
.ai-section ul li { margin-bottom: 3px; }
.ai-benchmark {
background: var(--l4-purple-bg);
border-left: 3px solid var(--l4-purple);
padding: 8px 12px;
border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0;
font-size: 0.82rem;
color: var(--gray-700);
margin-top: 10px;
}
.ai-privacy-note {
background: #fef3c7;
border-left: 3px solid var(--warning);
padding: 6px 10px;
border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0;
font-size: 0.78rem;
color: #92400e;
margin-top: 8px;
}
.msg-time {
font-size: 0.68rem;
color: var(--gray-400);
margin-top: 3px;
text-align: right;
}
.msg.ai .msg-time { text-align: left; }
/* ── Thinking bubble ────────────────────────── */
.thinking-bubble {
display: flex;
gap: 5px;
padding: 12px 16px;
background: var(--gray-100);
border-radius: 0 var(--border-radius) var(--border-radius) var(--border-radius);
width: fit-content;
}
.thinking-dot {
width: 7px; height: 7px;
background: var(--gray-400);
border-radius: 50%;
animation: thinkBounce 1.2s infinite;
}
.thinking-dot:nth-child(2) { animation-delay: 0.2s; }
.thinking-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes thinkBounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-8px); }
}
/* ── Portfolio summary card ─────────────────── */
.portfolio-header {
background: linear-gradient(135deg, #4c1d95, #6d28d9);
border-radius: var(--border-radius-lg);
padding: 18px 22px;
color: white;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.portfolio-stat {
text-align: center;
}
.portfolio-stat .val { font-size: 1.8rem; font-weight: 800; color: #c4b5fd; }
.portfolio-stat .lbl { font-size: 0.68rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.04em; }
/* ── Quick questions ────────────────────────── */
.quick-qs {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.quick-q {
background: var(--l4-purple-bg);
color: var(--l4-purple);
border: 1px solid #ddd6fe;
border-radius: 20px;
padding: 5px 12px;
font-size: 0.77rem;
cursor: pointer;
transition: background 0.2s;
}
.quick-q:hover { background: #ede9fe; }
.quick-q:focus-visible { outline: 2px solid var(--l4-purple); outline-offset: 2px; }
/* ── Privacy badge ──────────────────────────── */
.privacy-badge {
display: inline-flex;
align-items: center;
gap: 5px;
background: #d1fae5;
color: #065f46;
border-radius: 20px;
padding: 3px 10px;
font-size: 0.72rem;
font-weight: 600;
}
/* ── Empty state ────────────────────────────── */
.chat-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--gray-400);
text-align: center;
gap: 10px;
}
.chat-empty .ico { color: #c4b5fd; }
.chat-empty h4 { color: var(--gray-600); font-size: 0.95rem; }
.chat-empty p { font-size: 0.82rem; max-width: 360px; }
/* ── Sidebar tab ────────────────────────────── */
.tab-bar { display: flex; border-bottom: 1px solid var(--gray-200); margin-bottom: 0; }
.tab-btn {
padding: 10px 18px;
border: none;
background: none;
font-size: 0.84rem;
font-weight: 600;
color: var(--gray-500);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn.active { color: var(--l4-purple); border-bottom-color: var(--l4-purple); }
.tab-btn:focus-visible { outline: 2px solid var(--l4-purple); outline-offset: -2px; }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2>
<svg class="ico" aria-hidden="true" style="color:var(--l4-purple);margin-right:8px;"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-software"></use></svg>
AI Cross-Analysis
<span class="privacy-badge" style="margin-left:10px;">
<svg class="ico" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg> Dati aggregati — privacy garantita
</span>
</h2>
<div class="content-header-actions">
<span class="badge" style="background:var(--l4-purple-bg);color:var(--l4-purple);font-size:0.75rem;padding:4px 10px;border-radius:12px;">
<svg class="ico" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg> Solo Consulenti &amp; Admin
</span>
</div>
</header>
<div class="content-body">
<!-- Portfolio header -->
<div class="portfolio-header" id="portfolio-header">
<div>
<div style="font-size:0.72rem;opacity:0.6;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Il tuo portfolio</div>
<div style="font-size:1rem;font-weight:600;">Caricamento...</div>
</div>
</div>
<!-- Tabs -->
<div class="card" style="padding:0;overflow:hidden;">
<div class="tab-bar" role="tablist" aria-label="Sezioni AI Cross-Analysis">
<button class="tab-btn active" onclick="switchTab('chat')" role="tab" aria-selected="true" aria-controls="tab-chat">
<svg class="ico" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-comment"></use></svg> Chat AI
</button>
<button class="tab-btn" onclick="switchTab('history')" role="tab" aria-selected="false" aria-controls="tab-history">
<svg class="ico" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-clock"></use></svg> Storico
</button>
<button class="tab-btn" onclick="switchTab('portfolio')" role="tab" aria-selected="false" aria-controls="tab-portfolio">
<svg class="ico" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-chart-line"></use></svg> Portfolio dati
</button>
</div>
<!-- TAB: CHAT -->
<div class="tab-pane active" id="tab-chat" role="tabpanel">
<div class="chat-wrap">
<div class="chat-messages" id="chat-messages" aria-live="polite">
<div class="chat-empty" id="chat-empty">
<svg class="ico ico-lg" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-software"></use></svg>
<h4>Analisi AI del tuo portfolio clienti</h4>
<p>Fai una domanda sui dati aggregati delle tue organizzazioni. I dati vengono trasmessi in forma anonima e aggregata.</p>
</div>
</div>
<div class="chat-input-area">
<div class="quick-qs" id="quick-qs">
<button class="quick-q" onclick="setQuestion('Quali sono i gap di compliance più comuni nel mio portfolio?')">Gap più frequenti</button>
<button class="quick-q" onclick="setQuestion('Qual è il livello medio di rischio nelle mie organizzazioni?')">Livello di rischio medio</button>
<button class="quick-q" onclick="setQuestion('Quali categorie NIS2 sono meno implementate?')">Categorie NIS2 deboli</button>
<button class="quick-q" onclick="setQuestion('Quali organizzazioni hanno bisogno di intervento urgente?')">Intervento urgente</button>
<button class="quick-q" onclick="setQuestion('Com\'è lo stato della formazione nei miei clienti?')">Stato formazione</button>
<button class="quick-q" onclick="setQuestion('Benchmark: dove si posiziona il mio portfolio rispetto agli standard NIS2?')">Benchmark NIS2</button>
</div>
<div class="chat-input-row">
<label for="chat-input" class="visually-hidden">Domanda all'AI sul portfolio</label>
<textarea id="chat-input" placeholder="Es: Quali sono le aree più critiche nel mio portfolio clienti?" rows="2"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage();}"></textarea>
<button class="btn-send" onclick="sendMessage()" id="btn-send" aria-label="Invia domanda">
<svg class="ico" aria-hidden="true" style="color:#fff;"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-mail"></use></svg>
</button>
</div>
<div style="font-size:0.7rem;color:var(--gray-400);margin-top:6px;text-align:center;">
<svg class="ico" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-locked"></use></svg>
Dati anonimizzati e aggregati — nessun nome organizzazione inviato all'AI
</div>
</div>
</div>
</div>
<!-- TAB: HISTORY -->
<div class="tab-pane" id="tab-history" role="tabpanel">
<div style="padding:16px;" id="history-content">
<div class="spinner" style="margin:30px auto;display:block;"></div>
</div>
</div>
<!-- TAB: PORTFOLIO DATI -->
<div class="tab-pane" id="tab-portfolio" role="tabpanel">
<div style="padding:16px;" id="portfolio-content">
<div class="spinner" style="margin:30px auto;display:block;"></div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260613"></script>
<script src="/js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260615"></script>
<script src="/js/i18n.js?v=20260615"></script>
<script>
const SPRITE = '/vendor/bootstrap-italia/dist/svg/sprites.svg';
function ico(name, cls) { return `<svg class="ico ${cls||''}" aria-hidden="true"><use href="${SPRITE}#${name}"></use></svg>`; }
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
let PORTFOLIO = null; // dati aggregati portfolio
// ── Init ──────────────────────────────────────────────────────────
loadPortfolio();
async function loadPortfolio() {
try {
const r = await api.get('/cross-analysis/portfolio');
if (!r.success) {
if (r.error_code === 'INSUFFICIENT_ROLE') {
document.getElementById('portfolio-header').innerHTML = `
<div style="color:white;text-align:center;width:100%;">
${ico('it-lock','ico-lg')}<br>
<strong>Funzione riservata a Consulenti e Super Admin</strong>
</div>`;
document.querySelector('.card').innerHTML = `
<div style="padding:40px;text-align:center;color:var(--gray-500);">
${ico('it-lock','ico-lg')}
<p style="margin-top:12px;">Hai bisogno del ruolo <strong>Consultant</strong> o <strong>Super Admin</strong> per accedere all\'analisi cross-organizzazione.</p>
</div>`;
return;
}
throw new Error(r.message);
}
PORTFOLIO = r.data;
renderPortfolioHeader(PORTFOLIO);
renderPortfolioTab(PORTFOLIO);
} catch(e) {
console.error('Portfolio load error:', e);
document.getElementById('portfolio-header').innerHTML = `
<div style="color:white;opacity:0.7;">Errore caricamento portfolio</div>`;
}
}
function renderPortfolioHeader(d) {
const agg = d.aggregated || {};
const score = agg.avg_compliance_score ?? '—';
const totalRisks = agg.risks?.total_open ?? '—';
const trainPct = agg.training?.avg_completion_rate ?? '—';
document.getElementById('portfolio-header').innerHTML = `
<div>
<div style="font-size:0.72rem;opacity:0.6;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Il tuo portfolio</div>
<div style="font-size:1.1rem;font-weight:700;">${d.org_count} organizzazioni monitorate</div>
</div>
<div class="portfolio-stat"><div class="val">${score}${score!=='—'?'%':''}</div><div class="lbl">Compliance medio</div></div>
<div class="portfolio-stat"><div class="val">${totalRisks}</div><div class="lbl">Rischi aperti</div></div>
<div class="portfolio-stat"><div class="val">${trainPct}${trainPct!=='—'?'%':''}</div><div class="lbl">Formazione media</div></div>
<div class="portfolio-stat"><div class="val">${d.org_count}</div><div class="lbl">Organizzazioni</div></div>`;
}
function renderPortfolioTab(d) {
const agg = d.aggregated;
if (!agg) {
document.getElementById('portfolio-content').innerHTML = '<p class="text-muted">Nessun dato disponibile.</p>';
return;
}
let html = '<div class="grid-2" style="gap:16px;">';
// Settori
if (agg.by_sector && Object.keys(agg.by_sector).length) {
html += `<div class="card" style="margin:0;"><div class="card-header"><h4>Distribuzione Settoriale</h4></div><div class="card-body">`;
for (const [sec, cnt] of Object.entries(agg.by_sector)) {
const pct = Math.round(cnt / d.org_count * 100);
html += `<div style="margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;font-size:0.82rem;margin-bottom:3px;">
<span>${sec}</span><span style="font-weight:600;">${cnt} (${pct}%)</span>
</div>
<div style="height:4px;background:var(--gray-200);border-radius:2px;">
<div style="height:100%;width:${pct}%;background:var(--l4-purple);border-radius:2px;"></div>
</div>
</div>`;
}
html += `</div></div>`;
}
// Compliance score
if (agg.score_distribution) {
html += `<div class="card" style="margin:0;"><div class="card-header"><h4>Distribuzione Score Compliance</h4></div><div class="card-body">`;
const colors = {'0-20%':'var(--danger)','21-40%':'#f97316','41-60%':'var(--warning)','61-80%':'#84cc16','81-100%':'var(--secondary)'};
for (const [range, cnt] of Object.entries(agg.score_distribution)) {
if (cnt === 0) continue;
html += `<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;font-size:0.82rem;">
<div style="width:8px;height:8px;border-radius:50%;background:${colors[range]||'var(--gray-400)'};flex-shrink:0;"></div>
<span style="flex:1;">${range}</span>
<strong>${cnt} org</strong>
</div>`;
}
html += `</div></div>`;
}
// Categorie NIS2 deboli
if (agg.assessments?.avg_by_category && Object.keys(agg.assessments.avg_by_category).length) {
const cats = Object.entries(agg.assessments.avg_by_category).slice(0, 5);
html += `<div class="card" style="margin:0;grid-column:1/-1;"><div class="card-header"><h4>Score Medio per Categoria NIS2 (dal più basso)</h4></div><div class="card-body">`;
html += '<div class="step-cards" style="grid-template-columns:repeat(auto-fill,minmax(180px,1fr));">';
for (const [cat, score] of cats) {
const color = score < 30 ? 'var(--danger)' : score < 60 ? 'var(--warning)' : 'var(--secondary)';
html += `<div class="step-card" style="cursor:default;">
<div style="font-size:0.75rem;color:var(--gray-500);margin-bottom:4px;">${cat}</div>
<div style="font-size:1.4rem;font-weight:800;color:${color};">${score}%</div>
<div style="height:3px;background:var(--gray-200);border-radius:2px;margin-top:6px;">
<div style="height:100%;width:${score}%;background:${color};border-radius:2px;"></div>
</div>
</div>`;
}
html += '</div></div></div>';
}
html += '</div>';
document.getElementById('portfolio-content').innerHTML = html;
}
// ── Tab switching ─────────────────────────────────────────────────
function switchTab(tab) {
const tabs = ['chat','history','portfolio'];
document.querySelectorAll('.tab-btn').forEach((b, i) => {
const active = tabs[i] === tab;
b.classList.toggle('active', active);
b.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
if (tab === 'history') loadHistory();
}
// ── Send message ──────────────────────────────────────────────────
let isThinking = false;
async function sendMessage() {
if (isThinking) return;
const input = document.getElementById('chat-input');
const question = input.value.trim();
if (!question) return;
// Rimuovi empty state
const empty = document.getElementById('chat-empty');
if (empty) empty.remove();
// Mostra messaggio utente
appendMessage('user', question);
input.value = '';
autoResizeTextarea(input);
// Thinking bubble
isThinking = true;
document.getElementById('btn-send').disabled = true;
const thinkId = 'think-' + Date.now();
appendThinking(thinkId);
try {
const r = await api.post('/cross-analysis/analyze', { question });
removeThinking(thinkId);
if (r.success && r.data?.result) {
appendAIResponse(r.data.result, r.data.org_count);
} else {
appendError(r.message || 'Errore sconosciuto');
}
} catch(e) {
removeThinking(thinkId);
appendError('Errore di connessione: ' + e.message);
} finally {
isThinking = false;
document.getElementById('btn-send').disabled = false;
input.focus();
}
}
function appendMessage(role, text) {
const msgs = document.getElementById('chat-messages');
const time = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit'});
const iconName = role === 'user' ? 'it-user' : 'it-software';
msgs.innerHTML += `
<div class="msg ${role}">
<div class="msg-avatar">${ico(iconName)}</div>
<div class="msg-body">
<div class="msg-bubble">${escapeHtml(text)}</div>
<div class="msg-time">${time}</div>
</div>
</div>`;
msgs.scrollTop = msgs.scrollHeight;
}
function appendAIResponse(result, orgCount) {
const msgs = document.getElementById('chat-messages');
const time = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit'});
let inner = `<div class="ai-answer">${escapeHtml(result.answer || 'Nessuna risposta')}</div>`;
if (result.key_findings?.length) {
inner += `<div class="ai-section">
<div class="ai-section-title">${ico('it-search')} Risultati chiave</div>
<ul>${result.key_findings.map(f => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
</div>`;
}
if (result.recommendations?.length) {
inner += `<div class="ai-section">
<div class="ai-section-title">${ico('it-note')} Raccomandazioni</div>
<ul>${result.recommendations.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>
</div>`;
}
if (result.risk_areas?.length) {
inner += `<div class="ai-section">
<div class="ai-section-title">${ico('it-warning-circle')} Aree di attenzione</div>
<ul>${result.risk_areas.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>
</div>`;
}
if (result.benchmark_note) {
inner += `<div class="ai-benchmark">${ico('it-chart-line')} ${escapeHtml(result.benchmark_note)}</div>`;
}
if (result.privacy_note) {
inner += `<div class="ai-privacy-note">${ico('it-lock')} ${escapeHtml(result.privacy_note)}</div>`;
}
inner += `<div style="margin-top:8px;font-size:0.68rem;color:var(--gray-400);">Basato su ${orgCount} organizzazioni anonimizzate</div>`;
msgs.innerHTML += `
<div class="msg ai">
<div class="msg-avatar">${ico('it-software')}</div>
<div class="msg-body">
<div class="msg-bubble">${inner}</div>
<div class="msg-time">${time}</div>
</div>
</div>`;
msgs.scrollTop = msgs.scrollHeight;
}
function appendError(msg) {
const msgs = document.getElementById('chat-messages');
msgs.innerHTML += `
<div class="msg ai">
<div class="msg-avatar" style="background:var(--danger);">${ico('it-close')}</div>
<div class="msg-body">
<div class="msg-bubble" style="background:var(--danger-bg);color:var(--danger);">
${ico('it-warning-circle')} ${escapeHtml(msg)}
</div>
</div>
</div>`;
msgs.scrollTop = msgs.scrollHeight;
}
function appendThinking(id) {
const msgs = document.getElementById('chat-messages');
msgs.innerHTML += `
<div class="msg ai" id="${id}">
<div class="msg-avatar">${ico('it-software')}</div>
<div class="msg-body">
<div class="thinking-bubble">
<div class="thinking-dot"></div>
<div class="thinking-dot"></div>
<div class="thinking-dot"></div>
</div>
</div>
</div>`;
msgs.scrollTop = msgs.scrollHeight;
}
function removeThinking(id) {
document.getElementById(id)?.remove();
}
// ── History ───────────────────────────────────────────────────────
async function loadHistory() {
const el = document.getElementById('history-content');
el.innerHTML = '<div class="spinner" style="margin:30px auto;display:block;"></div>';
try {
const r = await api.get('/cross-analysis/history?limit=20');
if (!r.success) { el.innerHTML = '<p class="text-muted">Errore caricamento storico.</p>'; return; }
const rows = r.data?.history || [];
if (!rows.length) {
el.innerHTML = '<p class="text-muted" style="padding:20px;text-align:center;">Nessuna analisi precedente.</p>';
return;
}
el.innerHTML = rows.map(row => `
<div style="border-bottom:1px solid var(--gray-100);padding:12px 0;">
<div style="font-size:0.78rem;color:var(--gray-400);margin-bottom:4px;">
${new Date(row.created_at).toLocaleString('it-IT')}
</div>
<div style="font-size:0.84rem;font-weight:600;color:var(--gray-700);margin-bottom:4px;">
${escapeHtml((row.prompt_summary || '').replace(/^CROSS_ORG \[.*?\]: /, ''))}
</div>
<div style="font-size:0.8rem;color:var(--gray-500);">${escapeHtml(row.response_summary || '—')}</div>
</div>`).join('');
} catch(e) {
el.innerHTML = '<p class="text-muted">Errore: ' + escapeHtml(e.message) + '</p>';
}
}
// ── Helpers ───────────────────────────────────────────────────────
function setQuestion(q) {
document.getElementById('chat-input').value = q;
document.getElementById('chat-input').focus();
autoResizeTextarea(document.getElementById('chat-input'));
}
function autoResizeTextarea(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
}
document.getElementById('chat-input').addEventListener('input', function() {
autoResizeTextarea(this);
});
</script>
</body>
</html>
-361
View File
@@ -1,361 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Dashboard (BI) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (card/gauge/stat/modali) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260613b">
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar"></aside>
<!-- Main Content -->
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="dashboard.title">Dashboard</h1>
<div class="content-header-actions">
<span class="text-muted" id="header-date"></span>
</div>
</header>
<div class="content-body">
<!-- Compliance Score + Stats -->
<div class="grid-3 mb-24" style="grid-template-columns: 1fr 2fr;">
<!-- Gauge -->
<div class="card">
<div class="card-body text-center">
<div id="compliance-gauge">
<div class="spinner-lg" style="margin:40px auto;"></div>
</div>
<p class="text-muted mt-8" style="font-size:0.8rem;">Avanzamento implementazione misure Art.21 NIS2</p>
<p class="text-muted" style="font-size:0.7rem; margin-top:2px;">Misura l'implementazione tecnica, non la conformita' legale</p>
</div>
</div>
<!-- Stats Cards -->
<div>
<div class="stats-grid" id="stats-grid">
<div class="stat-card">
<div class="stat-card-icon danger">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd"/></svg>
</div>
<div class="stat-card-content">
<h4>Rischi Aperti</h4>
<div class="stat-card-value" id="stat-risks">--</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon warning">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z"/></svg>
</div>
<div class="stat-card-content">
<h4>Incidenti Attivi</h4>
<div class="stat-card-value" id="stat-incidents">--</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon success">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>
</div>
<div class="stat-card-content">
<h4>Policy Approvate</h4>
<div class="stat-card-value" id="stat-policies">--</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon primary">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838L7.667 9.088l1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zM9.3 16.573A9.026 9.026 0 007 14.935v-3.957l1.818.78a3 3 0 002.364 0l5.508-2.361a11.026 11.026 0 01.25 3.762 1 1 0 01-.89.89 8.968 8.968 0 00-5.35 2.524 1 1 0 01-1.4 0z"/></svg>
</div>
<div class="stat-card-content">
<h4>Formazione</h4>
<div class="stat-card-value" id="stat-training">--%</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card mb-24">
<div class="card-header">
<h3>Azioni Rapide</h3>
</div>
<div class="card-body">
<div class="quick-actions">
<button class="quick-action-btn" onclick="window.location.href='assessment-bi.html'">
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Nuovo Assessment
</button>
<button class="quick-action-btn" onclick="window.location.href='incidents-bi.html'">
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6z"/></svg>
Registra Incidente
</button>
<button class="quick-action-btn" onclick="generateAIPolicy()">
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
Genera Policy AI
</button>
</div>
</div>
</div>
<!-- Deadlines + Activity -->
<div class="grid-2">
<!-- Scadenze -->
<div class="card">
<div class="card-header">
<h3>Prossime Scadenze</h3>
</div>
<div class="card-body" id="deadlines-list">
<div class="spinner" style="margin:20px auto;"></div>
</div>
</div>
<!-- Attivita' Recenti -->
<div class="card">
<div class="card-header">
<h3>Attivita' Recenti</h3>
</div>
<div class="card-body" id="activity-list">
<div class="spinner" style="margin:20px auto;"></div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260613"></script>
<script src="/js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260615"></script>
<script src="/js/i18n.js?v=20260615"></script>
<script src="/js/help.js?v=20260615"></script>
<script>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
// ── Init ─────────────────────────────────────────────────
loadSidebar();
I18n.init();
HelpSystem.init();
// Data corrente nell'header
document.getElementById('header-date').textContent = new Date().toLocaleDateString('it-IT', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'
});
// ── Caricamento dati ─────────────────────────────────────
loadDashboard();
async function loadDashboard() {
try {
const result = await api.getDashboardOverview();
if (result.success && result.data) {
const data = result.data;
// Compliance gauge
const score = data.compliance_score != null ? Math.round(data.compliance_score) : 0;
document.getElementById('compliance-gauge').innerHTML = renderScoreGauge(score, 180);
// Stats - map backend response structure to UI
const openRisks = data.risks ? (parseInt(data.risks.total) || 0) : 0;
const activeIncidents = data.active_incidents || 0;
const approvedPolicies = Array.isArray(data.policies)
? data.policies.reduce((sum, p) => p.status === 'approved' || p.status === 'published' ? sum + parseInt(p.count) : sum, 0)
: 0;
const trainingTotal = data.training ? (parseInt(data.training.total) || 0) : 0;
const trainingCompleted = data.training ? (parseInt(data.training.completed) || 0) : 0;
const trainingPct = trainingTotal > 0 ? Math.round((trainingCompleted / trainingTotal) * 100) : 0;
document.getElementById('stat-risks').textContent = openRisks;
document.getElementById('stat-incidents').textContent = activeIncidents;
document.getElementById('stat-policies').textContent = approvedPolicies;
document.getElementById('stat-training').textContent = trainingPct + '%';
}
} catch (err) {
console.error('Dashboard load error:', err);
}
// Always load deadlines and activity from dedicated endpoints
loadIndividualData();
}
async function loadIndividualData() {
// Compliance score
try {
const scoreRes = await api.getComplianceScore();
if (scoreRes.success && scoreRes.data) {
// Preferisci la % controlli implementati; se 0/assente (org con gap
// analysis fatta ma senza modulo controlli) ripiega sul punteggio assessment.
const d = scoreRes.data;
const score = (d.avg_implementation && d.avg_implementation > 0) ? d.avg_implementation : (d.score || 0);
document.getElementById('compliance-gauge').innerHTML = renderScoreGauge(score, 180);
} else {
document.getElementById('compliance-gauge').innerHTML = renderScoreGauge(0, 180);
}
} catch (e) {
document.getElementById('compliance-gauge').innerHTML = renderScoreGauge(0, 180);
}
// Deadlines
try {
const dlRes = await api.getUpcomingDeadlines();
renderDeadlines(dlRes.success ? (dlRes.data || []) : []);
} catch (e) {
renderDeadlines([]);
}
// Activity
try {
const actRes = await api.getRecentActivity();
renderActivity(actRes.success ? (actRes.data || []) : []);
} catch (e) {
renderActivity([]);
}
}
function renderDeadlines(deadlines) {
const container = document.getElementById('deadlines-list');
if (!deadlines || deadlines.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"/></svg>
<h4>Nessuna scadenza imminente</h4>
<p>Le prossime scadenze appariranno qui.</p>
</div>`;
return;
}
const months = ['GEN','FEB','MAR','APR','MAG','GIU','LUG','AGO','SET','OTT','NOV','DIC'];
let html = '';
deadlines.slice(0, 5).forEach(dl => {
const d = new Date(dl.due_date || dl.date);
const now = new Date();
const diffDays = Math.ceil((d - now) / 86400000);
let urgency = 'normal';
if (diffDays <= 3) urgency = 'urgent';
else if (diffDays <= 7) urgency = 'soon';
html += `
<div class="deadline-item">
<div class="deadline-date ${urgency}">
<span class="day">${d.getDate()}</span>
<span class="month">${months[d.getMonth()]}</span>
</div>
<div class="deadline-info">
<div class="deadline-title">${escapeHtml(dl.title || dl.description || 'Scadenza')}</div>
<div class="deadline-desc">${escapeHtml(dl.category || dl.type || '')}</div>
</div>
${diffDays <= 3 ? '<span class="badge badge-danger">Urgente</span>' : diffDays <= 7 ? '<span class="badge badge-warning">Prossima</span>' : ''}
</div>`;
});
container.innerHTML = html;
}
function renderActivity(activities) {
const container = document.getElementById('activity-list');
if (!activities || activities.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
<h4>Nessuna attivita' recente</h4>
<p>Le attivita' appariranno qui quando inizierai ad usare la piattaforma.</p>
</div>`;
return;
}
let html = '<ul class="activity-list">';
activities.slice(0, 8).forEach(act => {
html += `
<li class="activity-item">
<div class="activity-icon">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
</div>
<div class="activity-content">
<div class="activity-text">${escapeHtml(act.description || act.message || act.action || '')}</div>
<div class="activity-time">${timeAgo(act.created_at || act.timestamp || act.date)}</div>
</div>
</li>`;
});
html += '</ul>';
container.innerHTML = html;
}
async function generateAIPolicy() {
showModal('Genera Policy con AI', `
<p style="margin-bottom:16px;">Seleziona la categoria per cui generare una policy automatica tramite intelligenza artificiale.</p>
<div class="form-group">
<label class="form-label">Categoria Policy</label>
<select class="form-select" id="ai-policy-category">
<option value="risk_management">Gestione del Rischio</option>
<option value="incident_response">Risposta agli Incidenti</option>
<option value="business_continuity">Continuita' Operativa</option>
<option value="supply_chain">Sicurezza Supply Chain</option>
<option value="access_control">Controllo degli Accessi</option>
<option value="encryption">Crittografia</option>
<option value="hr_security">Sicurezza del Personale</option>
<option value="asset_management">Gestione degli Asset</option>
</select>
</div>
`, {
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="doGeneratePolicy()">Genera</button>
`
});
}
async function doGeneratePolicy() {
const category = document.getElementById('ai-policy-category').value;
closeModal();
showNotification('Generazione policy in corso...', 'info');
try {
const result = await api.aiGeneratePolicy(category);
if (result.success) {
showNotification('Policy generata con successo!', 'success');
setTimeout(() => window.location.href = 'policies-bi.html', 1500);
} else {
showNotification(result.message || 'Errore nella generazione.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
</script>
</body>
</html>
-125
View File
@@ -1,125 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Password dimenticata - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 — self-hostato da /vendor (MAI CDN). Variante "-bi" della forgot-password.html. -->
<link rel="stylesheet" href="vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<style>
body { min-height: 100vh; display: flex; align-items: center; background: #f5f6f7; }
.auth-wrap { width: 100%; max-width: 460px; margin: 0 auto; padding: 24px; }
.auth-brand { text-align: center; margin-bottom: 24px; }
.auth-brand .brand-name { font-weight: 700; font-size: 1.5rem; color: #06c; }
.auth-brand .brand-name span { color: #17324d; }
.auth-brand .brand-sub { color: #5a6772; font-size: .9rem; margin-top: 4px; }
.ico { width: 1em; height: 1em; fill: currentColor; vertical-align: -.125em; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<main class="auth-wrap">
<div class="auth-brand">
<div class="brand-name">NIS2 <span>Agile</span></div>
<div class="brand-sub">Reimposta la tua password</div>
</div>
<div class="card card-bg has-bkg-grey shadow">
<div class="card-body">
<h1 class="h4 mb-3">Password dimenticata</h1>
<div class="alert alert-danger d-none" id="err" role="alert"></div>
<div class="alert alert-success d-none" id="ok" role="status"></div>
<p class="text-muted small mb-4">Inserisci l'indirizzo email associato al tuo account. Ti invieremo un link valido 30 minuti per impostare una nuova password.</p>
<form id="forgot-form" novalidate>
<div class="form-group">
<label for="email" class="active">Indirizzo Email</label>
<input type="email" id="email" name="email" class="form-control"
autocomplete="email" required aria-describedby="err">
</div>
<button type="submit" class="btn btn-primary w-100 mt-2" id="submit-btn">
Invia link
</button>
</form>
</div>
</div>
<p class="text-center mt-3 mb-0">
<a href="login-bi.html">
<svg class="ico me-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-left"></use></svg>
Torna al login
</a>
</p>
</main>
<script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }
</script>
<!-- Stessa logica della forgot-password.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260613"></script>
<script>
const form = document.getElementById('forgot-form');
const err = document.getElementById('err');
const ok = document.getElementById('ok');
const btn = document.getElementById('submit-btn');
form.addEventListener('submit', async function(e) {
e.preventDefault();
err.classList.add('d-none');
ok.classList.add('d-none');
const email = document.getElementById('email').value.trim();
if (!email) {
err.textContent = 'Inserisci l\'indirizzo email.';
err.classList.remove('d-none');
return;
}
btn.disabled = true;
btn.textContent = 'Invio in corso...';
try {
const res = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
});
const data = await res.json();
if (res.status === 429) {
err.textContent = data.message || 'Troppe richieste. Riprova più tardi.';
err.classList.remove('d-none');
} else if (data.success) {
ok.textContent = data.message;
ok.classList.remove('d-none');
form.style.display = 'none';
} else {
err.textContent = data.message || 'Errore. Riprova.';
err.classList.remove('d-none');
}
} catch (e) {
err.textContent = 'Errore di connessione al server.';
err.classList.remove('d-none');
} finally {
btn.disabled = false;
btn.textContent = 'Invia link';
}
});
</script>
</body>
</html>
-1186
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-570
View File
@@ -1,570 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>NIS2 Agile — Compliance NIS2 semplificata per aziende e consulenti</title>
<meta name="description" content="Piattaforma SaaS multi-tenant per la compliance NIS2 (EU 2022/2555 e D.Lgs. 138/2024). Doppia gap analysis Art.21 e misure di base ACN (Det. 164179/2025), risk management, incident response Art.23, portale fornitori e AI consulente ancorata alle fonti normative certe.">
<link rel="canonical" href="https://nis2.agile.software/">
<!-- Bootstrap Italia v2.18.1 — self-hostato da /vendor (MAI CDN). Variante "-bi" della index.html (landing). -->
<link rel="stylesheet" href="vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<style>
/* Solo layout: colori/font/forme vengono da Bootstrap Italia (Blu Italia #06c, Titillium Web) */
.hero-bi { background: #06c; color: #fff; padding: 88px 0 64px; }
.hero-bi h1 { font-weight: 700; }
.hero-badge { display: inline-flex; align-items: center; gap: 8px; background: rgba(255,255,255,.15);
border: 1px solid rgba(255,255,255,.3); border-radius: 20px; padding: 6px 16px; font-size: .8rem; font-weight: 700; margin-bottom: 16px; }
.hero-stat-num { font-size: 1.75rem; font-weight: 800; }
.hero-stat-label { font-size: .8rem; opacity: .85; }
.mock { background: #fff; color: #17324d; border-radius: 8px; overflow: hidden; box-shadow: 0 12px 40px rgba(0,0,0,.25); }
.mock-bar { background: #f5f6f7; padding: 10px 16px; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid #e6e9ed; }
.mock-bar .dot { width: 10px; height: 10px; border-radius: 50%; }
.mock-url { margin-left: 10px; font-size: .75rem; color: #5a6772; font-family: monospace; }
.mock-body { padding: 24px; }
.gauge { height: 8px; background: #e6e9ed; border-radius: 4px; overflow: hidden; margin: 12px 0 18px; }
.gauge > div { height: 100%; width: 73%; background: #06c; }
.icon-box { width: 48px; height: 48px; border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-bottom: 16px; background: #e6f0ff; color: #06c; }
.icon-box svg { width: 24px; height: 24px; fill: currentColor; }
.tag-badge { display: inline-block; font-size: .7rem; font-weight: 700; padding: 3px 10px; border-radius: 20px; margin-top: 12px; background: #e6f0ff; color: #06c; }
.ico { width: 1.1em; height: 1.1em; fill: currentColor; vertical-align: -.18em; }
.ico-check { width: 1em; height: 1em; fill: #008758; vertical-align: -.125em; margin-right: 6px; }
.section-eyebrow { font-size: .8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: #06c; margin-bottom: 8px; }
footer.app-footer { background: #17324d; color: #fff; padding: 40px 0; }
footer.app-footer a { color: #cbd5e1; }
</style>
<script defer data-domain="nis2.agile.software" src="https://analytics.agile.software/js/script.js"></script>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<!-- HEADER / NAV (Bootstrap Italia) -->
<header class="it-header-wrapper">
<div class="it-nav-wrapper">
<div class="it-header-center-wrapper">
<div class="container">
<div class="row">
<div class="col-12">
<div class="it-header-center-content-wrapper">
<div class="it-brand-wrapper">
<a href="/index-bi.html">
<svg class="icon icon-white" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-locked"></use></svg>
<div class="it-brand-text">
<h2 class="no_toc">NIS2 Agile</h2>
<h3 class="no_toc d-none d-md-block">Compliance NIS2 semplificata</h3>
</div>
</a>
</div>
<div class="it-right-zone">
<div class="it-socials d-none d-md-flex align-items-center" style="gap:8px;">
<a href="/login-bi.html" class="btn btn-outline-white btn-sm">Accedi</a>
<a href="#richiedi-accesso" class="btn btn-primary btn-sm" style="background:#fff;color:#06c;">Richiedi accesso</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</header>
<!-- HERO -->
<section class="hero-bi" id="hero">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-6">
<div class="hero-badge">
<svg class="ico" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check-circle"></use></svg>
D.Lgs. 138/2024 — In vigore dal 16 ottobre 2024
</div>
<h1 class="display-4 mb-3">Compliance NIS2 semplificata</h1>
<p class="lead mb-4">
Piattaforma SaaS multi-tenant per la conformità alla Direttiva NIS2 (EU 2022/2555) e al D.Lgs. 138/2024. Doppia gap analysis — Art.21 e <strong>misure di base ACN</strong> (Determinazione 164179/2025) — risk management, incident response Art.23, supply chain e un'AI consulente addestrata sul corpus normativo italiano. Tutto integrato.
</p>
<div class="hero-badge mb-4">
<svg class="ico" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg>
Accesso su invito — Richiedi il tuo codice per iniziare
</div>
<div class="d-flex flex-wrap" style="gap:12px; margin-bottom:32px;">
<a href="#richiedi-accesso" class="btn btn-lg" style="background:#fff;color:#06c;">Richiedi accesso</a>
<a href="/login-bi.html" class="btn btn-outline-white btn-lg">Ho un codice — Accedi</a>
</div>
<div class="row">
<div class="col-3"><div class="hero-stat-num">116</div><div class="hero-stat-label">Requisiti ACN mappati</div></div>
<div class="col-3"><div class="hero-stat-num">12</div><div class="hero-stat-label">Moduli integrati</div></div>
<div class="col-3"><div class="hero-stat-num">Art.23</div><div class="hero-stat-label">Incident response 72h</div></div>
<div class="col-3"><div class="hero-stat-num">RAG</div><div class="hero-stat-label">AI su fonti certe</div></div>
</div>
</div>
<div class="col-lg-6 d-none d-lg-block">
<div class="mock">
<div class="mock-bar">
<span class="dot" style="background:#d9364f;"></span>
<span class="dot" style="background:#e58a00;"></span>
<span class="dot" style="background:#008758;"></span>
<span class="mock-url">nis2.agile.software/dashboard</span>
</div>
<div class="mock-body">
<div class="d-flex justify-content-between align-items-center">
<strong>Compliance Score Art.21</strong>
<span style="font-size:1.75rem;font-weight:800;color:#06c;">73%</span>
</div>
<div class="gauge"><div></div></div>
<div class="row g-2">
<div class="col-6"><div class="p-2 border rounded"><div class="small text-muted text-uppercase">Rischi attivi</div><strong>3 HIGH</strong></div></div>
<div class="col-6"><div class="p-2 border rounded"><div class="small text-muted text-uppercase">Incidenti aperti</div><strong>1 Art.23</strong></div></div>
<div class="col-6"><div class="p-2 border rounded"><div class="small text-muted text-uppercase">Policy approvate</div><strong>8/12</strong></div></div>
<div class="col-6"><div class="p-2 border rounded"><div class="small text-muted text-uppercase">Formazione</div><strong>85%</strong></div></div>
</div>
<div class="alert alert-warning mt-3 mb-0 py-2">
<strong>Ransomware rilevato — Notifica ACN richiesta</strong><br>
<small>Scadenza 72h: 14h rimanenti · Art. 23 NIS2</small>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- IL PROBLEMA -->
<section class="py-5 bg-light" id="problema">
<div class="container">
<div class="text-center mb-5">
<div class="section-eyebrow">La sfida normativa</div>
<h2>L'obbligo NIS2 è già in vigore</h2>
<p class="text-muted col-lg-8 mx-auto">Il D.Lgs. 138/2024 è operativo dal 16 ottobre 2024. Le organizzazioni nei settori regolati devono aver avviato la registrazione ACN e l'adeguamento alle misure Art.21.</p>
</div>
<div class="row g-4">
<div class="col-md-4">
<div class="card card-bg h-100"><div class="card-body">
<div class="icon-box" style="background:#fdecec;color:#d9364f;"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning-circle"></use></svg></div>
<h3 class="h5">Sanzioni fino a €10M</h3>
<p class="text-muted">Entità essenziali: fino a <strong>€10M o 2%</strong> del fatturato globale annuo. Entità importanti: fino a <strong>€7M o 1,4%</strong>. L'ACN ha già avviato le prime verifiche.</p>
<span class="tag-badge">Art. 34 NIS2</span>
</div></div>
</div>
<div class="col-md-4">
<div class="card card-bg h-100"><div class="card-body">
<div class="icon-box" style="background:#fff5e6;color:#e58a00;"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg></div>
<h3 class="h5">18 Settori regolati</h3>
<p class="text-muted">11 settori ad alta criticità (energia, trasporti, bancario, infrastrutture digitali…) + 7 settori critici (gestione rifiuti, chimica, alimentare…). Oltre 10.000 aziende italiane interessate.</p>
<span class="tag-badge">D.Lgs. 138/2024</span>
</div></div>
</div>
<div class="col-md-4">
<div class="card card-bg h-100"><div class="card-body">
<div class="icon-box"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-clock"></use></svg></div>
<h3 class="h5">Scadenze stringenti</h3>
<p class="text-muted">Notifica incidenti significativi: early warning in <strong>24h</strong>, notifica formale in <strong>72h</strong>, report finale in <strong>1 mese</strong>. Nessuna possibilità di deroga.</p>
<span class="tag-badge">Art. 23 NIS2</span>
</div></div>
</div>
</div>
<div class="alert alert-info mt-4">
La piattaforma NIS2 Agile è allineata al <strong>D.Lgs. 138/2024</strong> (recepimento italiano della Direttiva UE 2022/2555), alle linee guida <strong>ACN</strong> e all'approccio risk-based <strong>ISO/IEC 27001</strong>.
</div>
</div>
</section>
<!-- PER CHI E' -->
<section class="py-5" id="target">
<div class="container">
<div class="text-center mb-5">
<div class="section-eyebrow">Per chi è</div>
<h2>Due target, un'unica piattaforma</h2>
<p class="text-muted col-lg-8 mx-auto">NIS2 Agile serve sia l'azienda che gestisce la propria compliance, sia il consulente che segue un portfolio di clienti.</p>
</div>
<div class="row g-4">
<div class="col-lg-6">
<div class="card card-bg h-100"><div class="card-body">
<div class="icon-box"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-locked"></use></svg></div>
<h3 class="h4">Azienda PMI / Enterprise</h3>
<p class="text-muted small">Settori essenziali e importanti — D.Lgs. 138/2024</p>
<ul class="list-unstyled">
<li><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Valuta il livello di compliance con gap analysis guidata da AI</li>
<li><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Gestisce rischi e misure di sicurezza Art.21 in un unico registro</li>
<li><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Notifica gli incidenti entro le scadenze Art.23 senza errori</li>
<li><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Genera policy di sicurezza personalizzate con AI in pochi minuti</li>
<li><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Monitora la supply chain e la formazione del personale</li>
<li><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Mantiene audit trail immutabile per le verifiche ACN</li>
</ul>
</div></div>
</div>
<div class="col-lg-6">
<div class="card card-bg h-100"><div class="card-body">
<div class="icon-box"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg></div>
<h3 class="h4">Consulente / CISO-as-a-Service</h3>
<p class="text-muted small">MSSP · Consulenti cybersecurity · CISO esterno</p>
<ul class="list-unstyled">
<li><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Gestisce un portfolio di clienti da un unico pannello di controllo</li>
<li><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Ogni cliente ha la propria dashboard dedicata e isolata</li>
<li><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Attiva licenze per i clienti tramite invite token B2B</li>
<li><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Monitora compliance score, rischi e incidenti di tutti i clienti</li>
<li><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Produce report esecutivi personalizzati per ogni azienda</li>
<li><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Accesso API e webhook per integrazioni con i propri sistemi SIEM</li>
</ul>
</div></div>
</div>
</div>
<div class="alert alert-info mt-4">
Il consulente attiva la licenza per il cliente tramite invite token — il cliente accede direttamente alla propria dashboard dedicata con visibilità completa sulla propria compliance.
</div>
</div>
</section>
<!-- LA SOLUZIONE -->
<section class="py-5 bg-light" id="soluzione">
<div class="container">
<div class="text-center mb-5">
<div class="section-eyebrow">I moduli</div>
<h2>Tutto ciò che serve per la compliance NIS2</h2>
<p class="text-muted col-lg-8 mx-auto">Dodici moduli integrati coprono l'intero perimetro dell'Art.21 e delle misure di base ACN, con un'AI (Claude) ancorata via RAG al corpus normativo italiano per analisi, suggerimenti e generazione documenti.</p>
</div>
<div class="row g-4">
<div class="col-md-4"><div class="card card-bg h-100 border-primary"><div class="card-body">
<div class="icon-box"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-list"></use></svg></div>
<h3 class="h6">Gap Analysis ACN <span class="badge bg-primary">Distintivo</span></h3>
<p class="text-muted small">L'unico assessment che mappa i requisiti <strong>puntuali</strong> della Determinazione ACN 164179/2025: 37 misure / 87 requisiti per i soggetti importanti, 43 / 116 per gli essenziali. Perimetro filtrato per livello, scoring per funzione del Framework Nazionale (GV/ID/PR/DE/RS/RC), piano d'azione sui gap.</p>
</div></div></div>
<div class="col-md-4"><div class="card card-bg h-100"><div class="card-body">
<div class="icon-box"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-clip"></use></svg></div>
<h3 class="h6">Gap Analysis Art.21</h3>
<p class="text-muted small">Questionario di 80 domande sui 10 ambiti dell'Art.21 per il primo orientamento. L'AI analizza le risposte, calcola lo score di maturità e produce raccomandazioni prioritizzate.</p>
</div></div></div>
<div class="col-md-4"><div class="card card-bg h-100"><div class="card-body">
<div class="icon-box" style="background:#fff5e6;color:#e58a00;"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning"></use></svg></div>
<h3 class="h6">Risk Management</h3>
<p class="text-muted small">Registro rischi con matrice 5×5 ISO 27005, piani di trattamento, monitoraggio e suggerimenti AI contestuali al settore dell'azienda.</p>
</div></div></div>
<div class="col-md-4"><div class="card card-bg h-100"><div class="card-body">
<div class="icon-box" style="background:#fdecec;color:#d9364f;"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning-circle"></use></svg></div>
<h3 class="h6">Incident Response Art.23</h3>
<p class="text-muted small">Workflow completo: early warning 24h, notifica 72h, report finale 30g. Timeline automatica, scadenze calcolate, email CSIRT integrate.</p>
</div></div></div>
<div class="col-md-4"><div class="card card-bg h-100"><div class="card-body">
<div class="icon-box"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-file"></use></svg></div>
<h3 class="h6">Policy Management</h3>
<p class="text-muted small">Generazione AI di policy di sicurezza personalizzate, workflow di approvazione, versioning e revisioni programmate con reminder automatici.</p>
</div></div></div>
<div class="col-md-4"><div class="card card-bg h-100"><div class="card-body">
<div class="icon-box"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-link"></use></svg></div>
<h3 class="h6">Supply Chain & Portale Fornitori</h3>
<p class="text-muted small">Registro fornitori critici con scoring rischio e clausole NIS2. Il <strong>portale fornitori</strong> consente al fornitore di compilare i questionari di sicurezza online con accesso passwordless (OTP / magic-link), campagne ricorrenti e solleciti automatici.</p>
</div></div></div>
<div class="col-md-4"><div class="card card-bg h-100"><div class="card-body">
<div class="icon-box" style="background:#f0faf4;color:#008758;"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-presentation"></use></svg></div>
<h3 class="h6">Formazione Art.20</h3>
<p class="text-muted small">Corsi obbligatori per ruolo, assegnazioni automatiche, tracking completamento e compliance formativa documentata per audit.</p>
</div></div></div>
<div class="col-md-4"><div class="card card-bg h-100"><div class="card-body">
<div class="icon-box"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-software"></use></svg></div>
<h3 class="h6">Asset & Dipendenze</h3>
<p class="text-muted small">Inventario asset critici con mappa delle dipendenze, classificazione per impatto NIS2 e integrazione con la valutazione dei rischi.</p>
</div></div></div>
<div class="col-md-4"><div class="card card-bg h-100"><div class="card-body">
<div class="icon-box" style="background:#fff5e6;color:#e58a00;"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-search"></use></svg></div>
<h3 class="h6">Audit & Report</h3>
<p class="text-muted small">Controlli ISO/IEC 27001, evidenze, audit trail immutabile (hash chain SHA-256), report esecutivo HTML e export CSV per organi di vigilanza.</p>
</div></div></div>
<div class="col-md-4"><div class="card card-bg h-100"><div class="card-body">
<div class="icon-box"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-comment"></use></svg></div>
<h3 class="h6">Whistleblowing Art.32</h3>
<p class="text-muted small">Canale segnalazioni anomalie di sicurezza anonime o firmate. Assegnazione a team CISO, stati open/investigating/closed, storico completo.</p>
</div></div></div>
<div class="col-md-4"><div class="card card-bg h-100"><div class="card-body">
<div class="icon-box"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-help-circle"></use></svg></div>
<h3 class="h6">AI Consulente (RAG)</h3>
<p class="text-muted small">Un assistente che risponde sul NIS2 ancorato a <strong>fonti certe</strong>: 203 requisiti ACN, Allegati 1-4 e testi normativi indicizzati in un vector database. Ogni affermazione cita la fonte e il sistema vieta riferimenti normativi inventati.</p>
</div></div></div>
<div class="col-md-4"><div class="card card-bg h-100"><div class="card-body">
<div class="icon-box" style="background:#f0faf4;color:#008758;"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-files"></use></svg></div>
<h3 class="h6">Knowledge Base multi-livello</h3>
<p class="text-muted small">Repository documentale con RAG a tre livelli di visibilità — <strong>vendor, studio di consulenza, singola organizzazione</strong> — con isolamento garantito tra clienti.</p>
</div></div></div>
</div>
</div>
</section>
<!-- 231 BANNER -->
<section class="py-5" id="lg231-banner">
<div class="container">
<div class="card card-bg" style="border-left:4px solid #e58a00;"><div class="card-body">
<h3 class="h5">Soggetto NIS2? Molto probabilmente sei soggetto anche al D.Lgs. 231/2001</h3>
<p class="text-muted">Le aziende nei settori essenziali e importanti adottano quasi sempre anche il Modello Organizzativo ex 231. NIS2 Agile e 231 Agile si integrano nativamente: i dati di compliance NIS2 (rischi, incidenti, controlli, score) alimentano automaticamente le evidenze per l'OdV 231, eliminando la duplicazione di lavoro.</p>
<div class="d-flex flex-wrap mb-3" style="gap:24px;">
<span><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Rischi NIS2 → evidenze MOG 231</span>
<span><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Audit trail condiviso</span>
<span><svg class="ico-check" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>OdV ha visibilità diretta sui controlli cyber</span>
</div>
<a href="https://lg231.agile.software/" target="_blank" rel="noopener" class="btn btn-outline-primary">
<svg class="ico me-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-external-link"></use></svg>
Scopri 231 Agile
</a>
</div></div>
</div>
</section>
<!-- COME FUNZIONA -->
<section class="py-5 bg-light" id="come-funziona">
<div class="container">
<div class="text-center mb-5">
<div class="section-eyebrow">Come funziona</div>
<h2>Operativo in pochi minuti</h2>
<p class="text-muted col-lg-8 mx-auto">Dall'onboarding al primo report di compliance, NIS2 Agile guida l'utente passo dopo passo.</p>
</div>
<div class="row g-4 text-center">
<div class="col-md-3"><div class="icon-box mx-auto"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg></div><h4 class="h6">1. Registrati</h4><p class="text-muted small">Crea il tuo account in 2 minuti. Scegli il ruolo (azienda o consulente) e configura la tua organizzazione.</p></div>
<div class="col-md-3"><div class="icon-box mx-auto"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg></div><h4 class="h6">2. Classifica l'azienda</h4><p class="text-muted small">Inserisci P.IVA e NIS2 Agile rileva automaticamente settore, dimensioni e classificazione (essenziale / importante).</p></div>
<div class="col-md-3"><div class="icon-box mx-auto"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check-circle"></use></svg></div><h4 class="h6">3. Gap Analysis</h4><p class="text-muted small">Parti dall'Art.21 per l'orientamento, poi passa alle misure di base ACN. L'AI analizza le risposte e produce il tuo piano di rientro prioritizzato.</p></div>
<div class="col-md-3"><div class="icon-box mx-auto"><svg aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-chart-line"></use></svg></div><h4 class="h6">4. Monitora e mantieni</h4><p class="text-muted small">Dashboard in tempo reale, gestisci rischi, incidenti, policy e formazione con alert e scadenze automatiche.</p></div>
</div>
</div>
</section>
<!-- FORM RICHIESTA ACCESSO -->
<section class="py-5" id="richiedi-accesso">
<div class="container">
<div class="text-center mb-5">
<div class="section-eyebrow">Richiedi accesso</div>
<h2>Ottieni il tuo codice invito</h2>
<p class="text-muted col-lg-8 mx-auto">La piattaforma è ad accesso controllato. Compila il form e ti invieremo il codice invito entro 24 ore.</p>
</div>
<div class="card card-bg shadow mx-auto" style="max-width:680px;"><div class="card-body p-4">
<div class="alert alert-danger d-none" id="formError" role="alert"></div>
<form id="inviteForm" novalidate>
<div class="row">
<div class="form-group col-md-6">
<label for="f-nome" class="active">Nome e Cognome <span aria-hidden="true">*</span></label>
<input type="text" id="f-nome" name="nome" class="form-control" required>
</div>
<div class="form-group col-md-6">
<label for="f-email" class="active">Email aziendale <span aria-hidden="true">*</span></label>
<input type="email" id="f-email" name="email" class="form-control" required>
</div>
<div class="form-group col-md-6">
<label for="f-azienda" class="active">Azienda / Studio <span aria-hidden="true">*</span></label>
<input type="text" id="f-azienda" name="azienda" class="form-control" required>
</div>
<div class="form-group col-md-6">
<label for="f-tel" class="active">Telefono</label>
<input type="tel" id="f-tel" name="telefono" class="form-control">
</div>
<div class="form-group col-md-6">
<label for="f-tipo" class="active">Tipo di utilizzo <span aria-hidden="true">*</span></label>
<select id="f-tipo" name="tipo" class="form-select" required>
<option value="">Seleziona...</option>
<option value="Azienda soggetta NIS2">Azienda soggetta NIS2</option>
<option value="Consulente / CISO esterno">Consulente / CISO esterno</option>
<option value="MSSP / Managed Security Provider">MSSP / Managed Security Provider</option>
<option value="IT Manager / Responsabile sicurezza">IT Manager / Responsabile sicurezza</option>
<option value="DPO / Legale / Compliance">DPO / Legale / Compliance</option>
<option value="Direzione / CEO / CTO">Direzione / CEO / CTO</option>
</select>
</div>
<div class="form-group col-md-6">
<label for="f-dip" class="active">N° dipendenti</label>
<select id="f-dip" name="n_dipendenti" class="form-select">
<option value="">Seleziona...</option>
<option value="<50">&lt;50</option>
<option value="50-249">50–249</option>
<option value="250-999">250–999</option>
<option value="1000+">1000+</option>
<option value="Studio multi-cliente">Studio multi-cliente</option>
</select>
</div>
<div class="form-group col-12">
<label for="f-int" class="active">Cosa ti interessa? <span aria-hidden="true">*</span></label>
<select id="f-int" name="interesse" class="form-select" required>
<option value="">Seleziona...</option>
<option value="Informazioni generali">Informazioni generali sul prodotto</option>
<option value="Demo guidata">Demo guidata con un consulente</option>
<option value="Accesso immediato">Accesso immediato alla piattaforma</option>
<option value="Integrazione B2B">Integrazione B2B / API per il mio sistema</option>
</select>
</div>
<div class="form-group col-12">
<label for="f-msg" class="active">Messaggio (opzionale)</label>
<textarea id="f-msg" name="messaggio" class="form-control" rows="3"></textarea>
</div>
</div>
<div class="form-check mt-2">
<input type="checkbox" id="ahubConsent" name="consent" required>
<label for="ahubConsent">Acconsento al trattamento dei miei dati ai sensi del GDPR (art. 13). I dati saranno usati esclusivamente per rispondere alla richiesta. <a href="https://agile.software/privacy" target="_blank" rel="noopener">Privacy policy</a> *</label>
</div>
<!-- Honeypot anti-bot -->
<div style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden" aria-hidden="true">
<label>Lascia vuoto</label>
<input type="text" name="website_url" tabindex="-1" autocomplete="off">
</div>
<button type="submit" class="btn btn-primary w-100 mt-3" id="submitBtn">Invia richiesta</button>
<p class="text-center text-muted small mt-2">
<svg class="ico" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg>
Risposta entro 24h lavorative · Powered by AgileHub
</p>
</form>
<div class="text-center d-none" id="formSuccess">
<svg class="icon icon-success" style="width:48px;height:48px;" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check-circle"></use></svg>
<h3 class="h4 mt-3">Richiesta inviata!</h3>
<p class="text-muted">Ti contatteremo entro 24 ore all'indirizzo fornito con il tuo codice di accesso personalizzato.<br><br>
<strong>Hai già ricevuto un codice?</strong><br>
<a href="/register-bi.html">Registrati ora con il tuo codice invito →</a></p>
</div>
</div></div>
<p class="text-center text-muted small mt-4">
Hai già un account? <a href="/login-bi.html">Accedi alla dashboard →</a>
</p>
</div>
</section>
<!-- CTA FINALE -->
<section class="py-5 bg-light" id="cta-finale">
<div class="container text-center">
<h2>La tua compliance NIS2 inizia con un codice</h2>
<p class="text-muted col-lg-6 mx-auto mb-4">Richiedi l'accesso oggi. Piattaforma ad accesso controllato: operativo in pochi minuti dal ricevimento del codice invito.</p>
<div class="d-flex flex-wrap justify-content-center" style="gap:12px;">
<a href="#richiedi-accesso" class="btn btn-primary btn-lg">Richiedi il codice invito</a>
<a href="/login-bi.html" class="btn btn-outline-primary btn-lg">Accedi</a>
</div>
<p class="text-muted small mt-3">Sei un consulente o MSSP? Il form di richiesta ti permette di attivare accesso per il tuo intero portfolio clienti.</p>
</div>
</section>
<!-- FOOTER -->
<footer class="app-footer">
<div class="container">
<div class="row align-items-center">
<div class="col-md-6">
<strong>NIS2 Agile</strong> <span class="text-white-50">by Agile Technology SRL</span>
</div>
<div class="col-md-6 text-md-end">
<div class="d-flex flex-wrap justify-content-md-end" style="gap:16px;">
<a href="#richiedi-accesso">Richiedi accesso</a>
<a href="/login-bi.html">Accedi</a>
<a href="https://lg231.agile.software/" target="_blank" rel="noopener">231 Agile</a>
<a href="https://agile.software/" target="_blank" rel="noopener">Agile Technology</a>
<a href="mailto:info@agile.software">info@agile.software</a>
</div>
</div>
</div>
<hr style="border-color:rgba(255,255,255,.15);">
<div class="small text-white-50">&copy; 2026 Agile Technology SRL — Direttiva UE 2022/2555 · D.Lgs. 138/2024</div>
</div>
</footer>
<script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }
</script>
<!-- Stessa logica della landing index.html: AgileHub Lead Pipeline (ZERO modifiche backend) -->
<script>
/* AgileHub Lead Pipeline — NIS2 Agile (tenant agile-technology) */
const AHUB_PK = '02fd04c434fb5e1a83d11ee001f88e2677e8660f';
const AHUB_ENDPOINT = 'https://agilehub.agile.software/api/public/applets/' + AHUB_PK + '/submit';
let __scrollMax = 0;
window.addEventListener('scroll', () => {
const h = document.documentElement;
const pct = Math.round((h.scrollTop || window.scrollY) / (h.scrollHeight - h.clientHeight) * 100);
if (pct > __scrollMax) __scrollMax = Math.min(pct, 100);
}, { passive: true });
const __pageLoadedAt = Date.now();
function ahubMetadata() {
const u = new URLSearchParams(location.search);
return {
utm_source: u.get('utm_source') || null,
utm_medium: u.get('utm_medium') || null,
utm_campaign: u.get('utm_campaign') || null,
utm_content: u.get('utm_content') || null,
utm_term: u.get('utm_term') || null,
referrer: document.referrer || null,
page_title: document.title,
page_url: location.href,
scroll_depth_pct: __scrollMax,
time_on_page_sec: Math.round((Date.now() - __pageLoadedAt) / 1000),
viewport: window.innerWidth + 'x' + window.innerHeight,
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
};
}
document.getElementById('inviteForm').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = document.getElementById('submitBtn');
const errEl = document.getElementById('formError');
const successEl = document.getElementById('formSuccess');
const form = e.target;
if (form.website_url && form.website_url.value) {
form.style.display = 'none';
successEl.classList.remove('d-none');
return;
}
if (!form.consent.checked) {
errEl.textContent = 'È necessario accettare il trattamento dati per inviare la richiesta.';
errEl.classList.remove('d-none');
return;
}
errEl.classList.add('d-none');
btn.disabled = true;
btn.textContent = 'Invio in corso...';
const payload = {
name: form.nome.value.trim(),
email: form.email.value.trim(),
phone: form.telefono.value.trim(),
company: form.azienda.value.trim(),
tipo: form.tipo.value,
consent: true,
fields: {
n_dipendenti: form.n_dipendenti.value,
interesse: form.interesse.value,
messaggio: form.messaggio.value.trim()
},
metadata: ahubMetadata()
};
try {
const res = await fetch(AHUB_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await res.json();
if (res.ok && json.success !== false) {
form.style.display = 'none';
successEl.classList.remove('d-none');
} else {
const code = json.error && json.error.code ? ' (' + json.error.code + ')' : '';
errEl.textContent = (json.error && json.error.message) || ('Errore nell\'invio' + code + '. Scrivici a info@agile.software.');
errEl.classList.remove('d-none');
btn.disabled = false;
btn.textContent = 'Invia richiesta';
}
} catch {
errEl.textContent = 'Errore di rete. Riprova o scrivici a info@agile.software.';
errEl.classList.remove('d-none');
btn.disabled = false;
btn.textContent = 'Invia richiesta';
}
});
</script>
</body>
</html>
-203
View File
@@ -1,203 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Modello Organizzativo SGSI (ISO 27001) (BI) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (wizard/SoA/tabelle) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260613b">
<style>
.isms-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; }
.isms-steps { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:20px; }
.isms-step { flex:1 1 140px; min-width:120px; padding:10px 12px; border:1.5px solid var(--gray-200,#e5e7eb); border-radius:10px; background:#fff; cursor:pointer; font-size:.82rem; text-align:center; }
.isms-step.active { border-color:var(--primary,#2563eb); background:#eff6ff; font-weight:700; }
.isms-step.done { border-color:#16a34a; }
.isms-step .num { display:inline-block; width:22px; height:22px; line-height:22px; border-radius:50%; background:var(--gray-100,#f3f4f6); font-weight:700; margin-bottom:4px; }
.isms-step.done .num { background:#16a34a; color:#fff; }
.isms-panel { display:none; }
.isms-panel.active { display:block; }
.isms-disclaimer { font-size:.8rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:12px 0; }
.raci-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; padding:8px 0; border-bottom:1px solid var(--gray-100,#f3f4f6); }
.raci-row input, .raci-row select { padding:6px 8px; border:1px solid var(--gray-200,#e5e7eb); border-radius:6px; font-size:.85rem; }
.soa-std { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; margin-bottom:14px; overflow:hidden; }
.soa-std-head { background:var(--gray-50,#f9fafb); padding:12px 16px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; }
.soa-std-body { padding:6px 14px 14px; display:none; }
.soa-std.open .soa-std-body { display:block; }
.soa-theme { font-size:.78rem; font-weight:700; text-transform:uppercase; color:var(--gray-500,#6b7280); margin:14px 0 6px; letter-spacing:.04em; }
.soa-ctrl { padding:12px 0; border-top:1px solid var(--gray-100,#f3f4f6); }
.soa-ctrl-head { display:flex; gap:10px; align-items:flex-start; justify-content:space-between; flex-wrap:wrap; }
.soa-code { display:inline-block; background:#eef2ff; color:#3730a3; font-weight:700; font-size:.72rem; padding:2px 8px; border-radius:6px; margin-right:6px; }
.soa-derived { background:#ecfdf5; color:#065f46; font-size:.68rem; padding:1px 6px; border-radius:5px; }
.soa-opts { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.soa-opt { min-height:34px; padding:5px 10px; border:1.5px solid var(--gray-200,#e5e7eb); border-radius:18px; background:#fff; cursor:pointer; font-size:.8rem; font-weight:600; }
.soa-opt.sel-implemented,.soa-opt.sel-verified { background:#16a34a; color:#fff; border-color:#16a34a; }
.soa-opt.sel-in_progress { background:#f59e0b; color:#fff; border-color:#f59e0b; }
.soa-opt.sel-not_started { background:#9ca3af; color:#fff; border-color:#9ca3af; }
.soa-justif { width:100%; margin-top:8px; padding:8px; border:1px solid var(--gray-200,#e5e7eb); border-radius:6px; font-size:.84rem; min-height:46px; }
.isms-field { margin-bottom:14px; }
.isms-field label { display:block; font-weight:600; font-size:.88rem; margin-bottom:4px; }
.isms-field textarea, .isms-field input { width:100%; padding:9px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; }
.isms-flags label { display:block; padding:8px 0; font-size:.9rem; }
.ck-item { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--gray-100,#f3f4f6); font-size:.9rem; }
.ck-yes { color:#16a34a; font-weight:700; } .ck-no { color:#9ca3af; }
.savehint { font-size:.78rem; color:var(--gray-400,#9ca3af); min-height:16px; }
/* Allinea l'icona sprite BI nel bottone Esporta col testo */
.content-header-actions .btn .icon { vertical-align:text-bottom; margin-right:4px; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="isms.title">Modello Organizzativo SGSI (ISO 27001)</h2>
<div class="content-header-actions">
<span class="savehint" id="isms-savehint" role="status" aria-live="polite"></span>
<button class="btn btn-outline btn-sm" onclick="ismsExportView()" data-i18n="isms.export">
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-file"></use></svg>
<span>Esporta SGSI</span>
</button>
</div>
</header>
<div class="content-body">
<div class="isms-intro">
<strong data-i18n="isms.intro.title">Costruisci il tuo Sistema di Gestione della Sicurezza delle Informazioni.</strong>
<span data-i18n="isms.intro.body">Procedura guidata ISO/IEC 27001:2022 (clausole 4-10 + Statement of Applicability). Lo Statement of Applicability viene pre-popolato dalle tue risposte alla Gap Analysis NIS2. Attiva le estensioni cloud ISO 27017/27018 dove pertinenti.</span>
</div>
<div class="isms-disclaimer" data-i18n="isms.disclaimer">Strumento di supporto e pre-audit: non costituisce una certificazione ISO 27001 né un parere professionale vincolante. Gli obblighi normativi in Italia derivano da NIS2 / D.Lgs. 138/2024 / Determinazioni ACN; ISO 27001/27017/27018 sono best practice.</div>
<div class="isms-steps" id="isms-steps"></div>
<!-- STEP 1 - Contesto & Ambito (cl.4) -->
<div class="isms-panel" id="panel-0">
<div class="card"><div class="card-body">
<h3 data-i18n="isms.s1.title">1. Contesto e Ambito (cl. 4)</h3>
<div class="isms-field"><label for="f-scope" data-i18n="isms.s1.scope">Ambito del SGSI (scope statement)</label><textarea id="f-scope" rows="3" placeholder="Es. Tutti i sistemi e processi a supporto dei servizi ICT erogati dalla sede di..."></textarea></div>
<div class="isms-field"><label for="f-ctx-int" data-i18n="isms.s1.ctx_int">Contesto interno</label><textarea id="f-ctx-int" rows="2"></textarea></div>
<div class="isms-field"><label for="f-ctx-ext" data-i18n="isms.s1.ctx_ext">Contesto esterno</label><textarea id="f-ctx-ext" rows="2"></textarea></div>
<div class="isms-field"><label for="f-parties" data-i18n="isms.s1.parties">Parti interessate (una per riga)</label><textarea id="f-parties" rows="3" placeholder="Clienti&#10;Autorità (ACN/CSIRT)&#10;Fornitori ICT"></textarea></div>
<div class="isms-field"><label for="f-boundaries" data-i18n="isms.s1.boundaries">Confini del SGSI</label><textarea id="f-boundaries" rows="2"></textarea></div>
<div class="isms-field"><label for="f-exclusions" data-i18n="isms.s1.exclusions">Esclusioni motivate</label><textarea id="f-exclusions" rows="2"></textarea></div>
<div class="isms-flags">
<strong data-i18n="isms.s1.cloud">Estensioni cloud (attivano i controlli condizionali nel SoA)</strong>
<label><input type="checkbox" id="f-uses-cloud"> <span data-i18n="isms.s1.uses_cloud">Usiamo servizi cloud pubblici (Microsoft 365, AWS, Google...) → ISO 27017</span></label>
<label><input type="checkbox" id="f-cloud-provider"> <span data-i18n="isms.s1.cloud_provider">Siamo fornitori di servizi cloud → ISO 27017 (lato provider)</span></label>
<label><input type="checkbox" id="f-pii-cloud"> <span data-i18n="isms.s1.pii_cloud">Trattiamo dati personali (PII) in cloud pubblico come responsabili → ISO 27018</span></label>
</div>
<button class="btn btn-primary" onclick="saveStep1()" data-i18n="isms.save_next">Salva e continua</button>
</div></div>
</div>
<!-- STEP 2 - Leadership (cl.5) -->
<div class="isms-panel" id="panel-1">
<div class="card"><div class="card-body">
<h3 data-i18n="isms.s2.title">2. Leadership: ruoli e responsabilità (cl. 5)</h3>
<p class="text-muted" data-i18n="isms.s2.hint">Definisci i ruoli del SGSI con la matrice RACI (Responsible, Accountable, Consulted, Informed).</p>
<div id="roles-list"></div>
<div class="raci-row" style="border-top:2px solid var(--gray-200,#e5e7eb); margin-top:10px;">
<input id="r-name" placeholder="Ruolo (es. CISO)" aria-label="Ruolo" style="flex:1 1 160px;">
<input id="r-resp" placeholder="Responsabilità" aria-label="Responsabilità" style="flex:2 1 220px;">
<select id="r-raci" aria-label="RACI"><option value="">RACI</option><option>R</option><option>A</option><option>C</option><option>I</option></select>
<button class="btn btn-sm btn-primary" onclick="addRole()" data-i18n="isms.s2.add">Aggiungi ruolo</button>
</div>
<button class="btn btn-outline" style="margin-top:14px;" onclick="goStep(2)" data-i18n="isms.next">Continua</button>
</div></div>
</div>
<!-- STEP 3 - Risk (cl.6) -->
<div class="isms-panel" id="panel-2">
<div class="card"><div class="card-body">
<h3 data-i18n="isms.s3.title">3. Risk: metodologia e obiettivi (cl. 6)</h3>
<div class="isms-field"><label for="f-method" data-i18n="isms.s3.method">Metodologia di risk assessment</label><textarea id="f-method" rows="3" placeholder="Es. ISO 27005 / NIST SP 800-30; scala probabilità×impatto 5×5 (già usata nel modulo Rischi)."></textarea></div>
<div class="isms-field"><label for="f-objectives" data-i18n="isms.s3.objectives">Obiettivi del SGSI (uno per riga)</label><textarea id="f-objectives" rows="3"></textarea></div>
<p class="text-muted" data-i18n="isms.s3.link">Il registro dei rischi vive nel modulo <a href="risks-bi.html">Rischi</a>: questo passo ne dichiara la metodologia.</p>
<button class="btn btn-primary" onclick="saveStep3()" data-i18n="isms.save_next">Salva e continua</button>
</div></div>
</div>
<!-- STEP 4 - SoA -->
<div class="isms-panel" id="panel-3">
<div class="card"><div class="card-body">
<h3 data-i18n="isms.s4.title">4. Statement of Applicability</h3>
<p class="text-muted" data-i18n="isms.s4.hint">I controlli sono pre-popolati dalle risposte NIS2 dove esiste corrispondenza (badge "da NIS2"). Conferma applicabilità, stato e motivazione per ciascuno.</p>
<div style="margin:10px 0;">
<button class="btn btn-sm btn-outline" onclick="deriveSoa()" data-i18n="isms.s4.derive">Ri-deriva dal NIS2</button>
<span id="soa-stats" class="text-muted" style="margin-left:12px; font-size:.85rem;"></span>
</div>
<div id="soa-groups"></div>
<button class="btn btn-outline" style="margin-top:14px;" onclick="goStep(4)" data-i18n="isms.next">Continua</button>
</div></div>
</div>
<!-- STEP 5 - Documented information (cl.7-8) -->
<div class="isms-panel" id="panel-4">
<div class="card"><div class="card-body">
<h3 data-i18n="isms.s5.title">5. Documented Information (cl. 7-8)</h3>
<div class="raci-row" style="border:none;">
<label for="d-type" class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);">Tipo di documento</label>
<select id="d-type" style="flex:1 1 200px;" aria-label="Tipo di documento">
<option value="policy_sgsi">Policy SGSI</option>
<option value="dichiarazione_applicabilita">Dichiarazione di Applicabilità</option>
<option value="politica_controllo_accessi">Politica controllo accessi</option>
<option value="procedura_incident">Procedura gestione incidenti</option>
<option value="procedura_continuita">Procedura continuità operativa</option>
<option value="politica_fornitori">Politica sicurezza fornitori</option>
</select>
<button class="btn btn-sm btn-primary" onclick="aiGenDoc()" id="btn-aigen" data-i18n="isms.s5.aigen">Genera bozza con AI</button>
</div>
<div class="savehint" id="aigen-hint" role="status" aria-live="polite"></div>
<div id="docs-list" style="margin-top:14px;"></div>
<button class="btn btn-outline" style="margin-top:14px;" onclick="goStep(5)" data-i18n="isms.next">Continua</button>
</div></div>
</div>
<!-- STEP 6 - Monitoring & Readiness (cl.9-10) -->
<div class="isms-panel" id="panel-5">
<div class="card"><div class="card-body">
<h3 data-i18n="isms.s6.title">6. Monitoraggio e Miglioramento (cl. 9-10)</h3>
<p class="text-muted" data-i18n="isms.s6.hint">Gli audit interni e le non conformità si gestiscono nei moduli esistenti: <a href="reports-bi.html">Audit & Report</a> e NCR/CAPA.</p>
<h4 data-i18n="isms.s6.readiness">Completamento del SGSI</h4>
<div style="text-align:center; margin:14px 0;">
<div style="font-size:2.6rem; font-weight:800;" id="readiness-pct">0%</div>
</div>
<div id="readiness-checklist"></div>
</div></div>
</div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260613"></script>
<script src="/js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260615"></script>
<script src="/js/i18n.js?v=20260615"></script>
<script src="/js/help.js?v=20260615"></script>
<script src="/js/isms.js"></script>
</body>
</html>
-179
View File
@@ -1,179 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Knowledge Base (BI) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (lista doc, search) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260613b">
<style>
.kb-scope-radios { display: flex; gap: 18px; flex-wrap: wrap; font-size: .9rem; }
.kb-scope-radios label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.kb-share-block { margin-top: 12px; }
.kb-doc-list { margin-top: 24px; }
.kb-doc-row { padding: 10px 12px; border-bottom: 1px solid var(--gray-100); display: flex; justify-content: space-between; align-items: center; }
.kb-scope-badge { font-size: .72rem; padding: 2px 8px; border-radius: 10px; font-weight: 600; }
.kb-scope-badge.SYSTEM { background: #DBEAFE; color: #1E40AF; }
.kb-scope-badge.FIRM { background: #FEF3C7; color: #92400E; }
.kb-scope-badge.ORG { background: #DCFCE7; color: #166534; }
/* Allinea l'icona sprite BI dentro i bottoni del design system esistente */
.btn .icon { fill: currentColor; vertical-align: middle; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h1 class="h2">Knowledge Base</h1>
<div class="content-header-actions">
<button class="btn btn-primary" id="btn-kb-upload">
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-upload"></use></svg>
Carica documento
</button>
</div>
</header>
<div class="content-body">
<p class="text-muted" style="margin-top:0;">
Carica documenti (procedure, policy, normative, training material) che l'AI usera' per
rispondere alle domande con citazioni precise. La visibilita' segue il modello a 3 livelli:
<strong>SYSTEM</strong> (vendor), <strong>FIRM</strong> (studio + condivisioni esplicite),
<strong>ORG</strong> (singola organizzazione cliente).
</p>
<!-- Form upload (hidden by default) -->
<div class="card" id="kb-form-card" style="display:none;margin-top:16px;">
<div class="card-body">
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-group">
<label for="kb-title">Titolo</label>
<input type="text" id="kb-title" class="form-control" placeholder="Es: Procedura interna gestione incidenti NIS2">
</div>
<div class="form-group">
<label for="kb-entity-type">Tipo</label>
<select id="kb-entity-type" class="form-control">
<option value="custom">Procedura interna</option>
<option value="platform_guide">Linea guida</option>
<option value="csrd">Normativa</option>
<option value="training">Training</option>
</select>
</div>
</div>
<div class="form-group" style="margin-top:12px;">
<label for="kb-text">Contenuto (testo libero, 50&ndash;50.000 caratteri)</label>
<textarea id="kb-text" rows="8" class="form-control"
placeholder="Incolla qui il testo della procedura, della checklist o del template..."></textarea>
</div>
<div style="margin-top:12px;border-top:1px solid var(--gray-100);padding-top:12px;">
<label style="font-weight:600;font-size:.85rem;">Visibilita'</label>
<div class="kb-scope-radios" style="margin-top:6px;">
<label data-scope-opt="SYSTEM" style="display:none;">
<input type="radio" name="kb-scope" value="SYSTEM"> Sistema (super_admin)
</label>
<label data-scope-opt="FIRM" style="display:none;">
<input type="radio" name="kb-scope" value="FIRM"> Studio + condivisioni
</label>
<label data-scope-opt="ORG">
<input type="radio" name="kb-scope" value="ORG" checked> Solo organizzazione corrente
</label>
</div>
</div>
<div id="kb-share-block" class="kb-share-block" style="display:none;">
<label for="kb-shared-with" style="font-size:.78rem;color:var(--gray-600);">
Organizzazioni clienti destinatarie (Ctrl/Cmd per selezione multipla, opzionale):
</label>
<select id="kb-shared-with" multiple size="5" class="form-control">
<option value="">Caricamento organizzazioni del firm...</option>
</select>
<div style="font-size:.72rem;color:var(--gray-500);margin-top:4px;">
Lascia vuoto per condividere solo con i collaboratori dello studio.
</div>
</div>
<div style="margin-top:16px;display:flex;gap:8px;align-items:center;">
<button class="btn btn-primary" id="btn-kb-submit">Indicizza documento</button>
<button class="btn btn-secondary" id="btn-kb-cancel">Annulla</button>
<span id="kb-status" role="status" aria-live="polite" style="margin-left:8px;font-size:.85rem;"></span>
</div>
</div>
</div>
<!-- Lista documenti -->
<div class="card kb-doc-list">
<div class="card-header">
<h3>Documenti caricati visibili</h3>
</div>
<div class="card-body">
<div id="kb-doc-list">
<div class="text-muted">Caricamento...</div>
</div>
</div>
</div>
<!-- Search preview -->
<div class="card" style="margin-top:24px;">
<div class="card-header"><h3>Test search semantica</h3></div>
<div class="card-body">
<div style="display:flex;gap:8px;">
<label for="kb-search-query" class="sr-only visually-hidden">Query di ricerca semantica</label>
<input type="text" id="kb-search-query" class="form-control" placeholder="Es: come gestire un incidente NIS2?" aria-label="Query di ricerca semantica">
<button class="btn btn-secondary" id="btn-kb-search">
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-search"></use></svg>
Cerca
</button>
</div>
<div id="kb-search-results" style="margin-top:12px;"></div>
</div>
</div>
</div>
</main>
</div>
<!-- Stessa logica JS della pagina live (parita' funzionale assoluta, zero backend). -->
<script src="/js/common.js?v=20260615"></script>
<script src="/js/api.js?v=20260613"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260615"></script>
<script src="/js/i18n.js?v=20260615"></script>
<script src="/js/help.js?v=20260615"></script>
<script src="/js/kb.js"></script>
<script>
// Gate auth + chrome come le altre pagine -bi.
if (typeof checkAuth === 'function' && !checkAuth()) {
// checkAuth reindirizza a login se non autenticato
} else {
if (typeof loadSidebar === 'function') loadSidebar();
if (typeof I18n !== 'undefined' && I18n.init) I18n.init();
if (typeof HelpSystem !== 'undefined' && HelpSystem.init) HelpSystem.init();
}
</script>
</body>
</html>
-138
View File
@@ -1,138 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Accedi - NIS2 Agile</title>
<!-- PILOTA Bootstrap Italia v2.18.1 (Opzione B). Self-hostato: asset serviti da /vendor (regola 'MAI CDN'). -->
<link rel="stylesheet" href="vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<style>
/* solo layout di centratura: i colori/font/forme vengono da Bootstrap Italia */
body { min-height: 100vh; display: flex; align-items: center; background: #f5f6f7; }
.auth-wrap { width: 100%; max-width: 460px; margin: 0 auto; padding: 24px; }
.auth-brand { text-align: center; margin-bottom: 24px; }
.auth-brand .brand-name { font-weight: 700; font-size: 1.5rem; color: #06c; }
.auth-brand .brand-name span { color: #17324d; }
.auth-brand .brand-sub { color: #5a6772; font-size: .9rem; margin-top: 4px; }
.pw-wrap { position: relative; }
.pw-wrap .form-control { padding-right: 44px; }
.pw-toggle { position: absolute; right: 8px; top: 8px; background: none; border: none;
color: #5a6772; cursor: pointer; padding: 6px; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<main class="auth-wrap">
<div class="auth-brand">
<div class="brand-name">NIS2 <span>Agile</span></div>
<div class="brand-sub">Piattaforma di compliance NIS2</div>
</div>
<div class="card card-bg has-bkg-grey shadow">
<div class="card-body">
<h1 class="h4 mb-3">Accedi</h1>
<div class="alert alert-danger d-none" id="login-error" role="alert"></div>
<form id="login-form" novalidate>
<div class="form-group">
<label for="email" class="active">Indirizzo Email</label>
<input type="email" id="email" name="email" class="form-control"
autocomplete="email" required aria-describedby="login-error">
</div>
<div class="form-group">
<label for="password" class="active">Password</label>
<div class="pw-wrap">
<input type="password" id="password" name="password" class="form-control"
autocomplete="current-password" required>
<button type="button" class="pw-toggle" id="pw-toggle" tabindex="-1"
aria-label="Mostra o nascondi la password"
onclick="(function(){var i=document.getElementById('password');i.type=(i.type==='password')?'text':'password';})()">
<svg class="icon icon-sm" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-password-visible"></use></svg>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100 mt-2" id="login-btn">Accedi</button>
<p class="text-center mt-3 mb-0">
<a href="forgot-password-bi.html">Password dimenticata?</a>
</p>
</form>
</div>
</div>
<p class="text-center mt-3 small">
Non hai un account? <a href="register-bi.html">Registrati</a>
</p>
<p class="text-center small text-muted">
Accedendo accetti i
<a href="https://agentai.agile.software/terms" target="_blank" rel="noopener">Termini di Servizio</a>
e la <a href="https://agentai.agile.software/privacy" target="_blank" rel="noopener">Privacy Policy</a>.
</p>
</main>
<script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
// Carica i web font ufficiali (Titillium Web, ecc.) — richiesto da Bootstrap Italia
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts');
}
</script>
<!-- Stessa logica della login attuale: ZERO modifiche backend -->
<script src="js/api.js?v=20260613"></script>
<script src="js/common.js?v=20260615"></script>
<script>
if (api.isAuthenticated()) { window.location.href = 'dashboard-bi.html'; }
const form = document.getElementById('login-form');
const errorEl = document.getElementById('login-error');
const loginBtn = document.getElementById('login-btn');
function showError(msg) { errorEl.textContent = msg; errorEl.classList.remove('d-none'); }
function hideError() { errorEl.classList.add('d-none'); }
form.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
if (!email || !password) { showError('Inserisci email e password.'); return; }
loginBtn.disabled = true;
loginBtn.textContent = 'Accesso in corso...';
try {
const result = await api.login(email, password);
if (result.success) {
const isConsultant = result.data.user && result.data.user.role === 'consultant';
const hasOrgs = result.data.organizations && result.data.organizations.length > 0;
if (!hasOrgs) window.location.href = 'onboarding-bi.html';
else if (isConsultant) window.location.href = 'companies.html';
else window.location.href = 'dashboard-bi.html';
} else {
showError(result.message || 'Credenziali non valide.');
}
} catch (err) {
showError('Errore di connessione al server.');
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Accedi';
}
});
</script>
</body>
</html>
-286
View File
@@ -1,286 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Aggiornamenti Normativi (BI) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (card aggiornamenti/badge/modali) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260613b">
<style>
.impact-badge { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; }
.impact-critical { background: var(--danger-bg); color: var(--danger); }
.impact-high { background: #fff7ed; color: #c2410c; }
.impact-medium { background: var(--warning-bg); color: #a16207; }
.impact-low { background: var(--gray-100); color: var(--gray-600); }
.impact-informational { background: rgba(6,182,212,0.1); color: #0891b2; }
.ack-badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 600; }
.ack-done { background: var(--success-bg); color: var(--success); }
.ack-pending { background: var(--warning-bg); color: #a16207; }
.source-chip { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 700; background: var(--gray-100); color: var(--gray-600); font-family: monospace; }
.update-card { border: 1px solid var(--gray-200); border-radius: var(--border-radius-lg); padding: 20px; margin-bottom: 16px; transition: all var(--transition-fast); }
.update-card:hover { border-color: var(--primary); box-shadow: 0 2px 8px rgba(6,182,212,0.1); }
.update-card.action-required { border-left: 3px solid var(--warning); }
.update-card.acked { opacity: 0.7; }
.ack-progress { height: 6px; background: var(--gray-200); border-radius: 3px; overflow: hidden; }
.ack-progress-bar { height: 100%; background: var(--primary); border-radius: 3px; transition: width 0.5s ease; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<div class="page-header">
<div class="page-header-content">
<h1 class="page-title">Aggiornamenti Normativi</h1>
<p class="page-subtitle">Feed NIS2 / ACN / DORA — Presa visione obbligatoria per audit compliance</p>
</div>
<div class="page-header-actions">
<button class="btn btn-secondary" onclick="loadPending()">
<svg class="icon icon-sm" aria-hidden="true" style="margin-right:6px;"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-clock"></use></svg>
Solo Pendenti
</button>
</div>
</div>
<!-- Stats -->
<div class="stats-grid mb-24" id="stats-grid">
<div class="stat-card"><div class="spinner"></div></div>
</div>
<!-- Ack Progress bar -->
<div class="card mb-24">
<div class="card-body" style="padding:16px 20px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="font-size:0.875rem; font-weight:600; color:var(--gray-700);">Avanzamento presa visione</span>
<span id="ack-pct" style="font-size:0.875rem; font-weight:700; color:var(--primary);">—</span>
</div>
<div class="ack-progress">
<div class="ack-progress-bar" id="ack-bar" style="width:0%"></div>
</div>
<p style="font-size:0.75rem; color:var(--gray-500); margin-top:6px;">Documentare la presa visione degli aggiornamenti normativi è richiesto per la compliance NIS2 Art.21.</p>
</div>
</div>
<!-- Filters -->
<div class="card mb-16">
<div class="card-body" style="padding:14px 20px;">
<div style="display:flex; gap:12px; flex-wrap:wrap; align-items:center;">
<label for="filter-source" class="visually-hidden">Filtra per fonte normativa</label>
<select id="filter-source" class="form-control" style="width:auto;" onchange="loadUpdates()" aria-label="Filtra per fonte normativa">
<option value="">Tutte le fonti</option>
<option value="dlgs_138_2024">D.Lgs. 138/2024</option>
<option value="nis2_directive">Direttiva NIS2</option>
<option value="acn_guideline">ACN Guideline</option>
<option value="dora">DORA</option>
<option value="enisa">ENISA</option>
<option value="iso27001">ISO 27001</option>
</select>
<label for="filter-impact" class="visually-hidden">Filtra per livello di impatto</label>
<select id="filter-impact" class="form-control" style="width:auto;" onchange="loadUpdates()" aria-label="Filtra per livello di impatto">
<option value="">Tutti i livelli</option>
<option value="critical">Critico</option>
<option value="high">Alto</option>
<option value="medium">Medio</option>
<option value="low">Basso</option>
<option value="informational">Informativo</option>
</select>
<label style="display:flex; align-items:center; gap:8px; font-size:0.875rem; cursor:pointer;">
<input type="checkbox" id="filter-action" onchange="loadUpdates()" style="accent-color:var(--primary);">
Solo azione richiesta
</label>
</div>
</div>
</div>
<div id="updates-container"><div class="spinner" style="margin:60px auto;"></div></div>
</main>
</div>
<script src="/js/api.js?v=20260613"></script>
<script src="/js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260615"></script>
<script src="/js/i18n.js?v=20260615"></script>
<script src="/js/help.js?v=20260615"></script>
<script>
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
const sourceLabels = {
nis2_directive: 'NIS2 Directive', dlgs_138_2024: 'D.Lgs. 138/2024',
acn_guideline: 'ACN', dora: 'DORA', enisa: 'ENISA', iso27001: 'ISO 27001', other: 'Altro'
};
const impactLabels = {
critical: 'Critico', high: 'Alto', medium: 'Medio', low: 'Basso', informational: 'Informativo'
};
async function loadStats() {
try {
const result = await api.request('GET', '/normative/stats');
if (result.success) renderStats(result.data);
} catch (e) {}
}
function renderStats(d) {
document.getElementById('stats-grid').innerHTML = `
<div class="stat-card">
<div class="stat-label">Aggiornamenti Totali</div>
<div class="stat-value">${d.total_updates || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Prese Visione</div>
<div class="stat-value" style="color:var(--success);">${d.acknowledged || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Pendenti</div>
<div class="stat-value" style="color:var(--warning);">${d.pending || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Compliance ACK</div>
<div class="stat-value" style="color:var(--primary);">${d.ack_rate || 0}%</div>
</div>`;
document.getElementById('ack-pct').textContent = (d.ack_rate || 0) + '%';
document.getElementById('ack-bar').style.width = (d.ack_rate || 0) + '%';
}
async function loadUpdates() {
const container = document.getElementById('updates-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
try {
const params = new URLSearchParams();
const source = document.getElementById('filter-source').value;
const impact = document.getElementById('filter-impact').value;
const action = document.getElementById('filter-action').checked;
if (source) params.append('source', source);
if (impact) params.append('impact', impact);
if (action) params.append('action_required', '1');
const result = await api.request('GET', '/normative/list?' + params);
if (result.success) renderUpdates(result.data.updates || []);
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore caricamento aggiornamenti</h4></div>';
}
}
async function loadPending() {
const container = document.getElementById('updates-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
try {
const result = await api.request('GET', '/normative/pending');
if (result.success) renderUpdates(result.data.pending || [], true);
} catch (e) { container.innerHTML = '<div class="empty-state"><h4>Errore.</h4></div>'; }
}
function renderUpdates(updates, pendingOnly) {
const container = document.getElementById('updates-container');
if (!updates.length) {
container.innerHTML = `<div class="empty-state">
<svg class="icon" aria-hidden="true" style="width:40px;height:40px;color:var(--gray-300)"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-file"></use></svg>
<h4>${pendingOnly ? 'Nessun aggiornamento pendente — tutto preso in visione!' : 'Nessun aggiornamento trovato'}</h4>
</div>`;
return;
}
let html = '';
updates.forEach(u => {
const acked = u.is_acknowledged || !!(u.acknowledged_at);
const actionClass = u.action_required ? 'action-required' : '';
const ackedClass = acked ? 'acked' : '';
const domains = (u.affected_domains || []).map(d =>
`<span class="source-chip">${escapeHtml(d)}</span>`
).join(' ');
html += `<div class="update-card ${actionClass} ${ackedClass}" id="update-${u.id}">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
<div style="flex:1;">
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:8px;">
<span class="source-chip">${escapeHtml(sourceLabels[u.source] || u.source)}</span>
<span class="impact-badge impact-${u.impact_level}">${escapeHtml(impactLabels[u.impact_level] || u.impact_level)}</span>
${u.action_required ? '<span style="font-size:0.7rem; font-weight:700; color:var(--warning); background:var(--warning-bg); padding:2px 8px; border-radius:4px;">Azione richiesta</span>' : ''}
${acked
? `<span class="ack-badge ack-done"><svg class="icon" aria-hidden="true" style="width:11px;height:11px;fill:currentColor;"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Preso in visione ${u.ack_by_name ? 'da ' + escapeHtml(u.ack_by_name) : ''}</span>`
: '<span class="ack-badge ack-pending">Da prendere in visione</span>'
}
</div>
<h3 style="font-size:1rem; font-weight:700; color:var(--gray-900); margin-bottom:6px;">${escapeHtml(u.title)}</h3>
${u.reference ? `<p style="font-size:0.75rem; color:var(--gray-500); margin-bottom:8px;"><code>${escapeHtml(u.reference)}</code></p>` : ''}
<p style="font-size:0.8125rem; color:var(--gray-600); line-height:1.6; margin-bottom:10px;">${escapeHtml(u.summary)}</p>
${domains ? `<div style="display:flex; gap:6px; flex-wrap:wrap;">${domains}</div>` : ''}
</div>
<div style="display:flex; flex-direction:column; gap:8px; align-items:flex-end; flex-shrink:0;">
${u.effective_date ? `<p style="font-size:0.75rem; color:var(--gray-500);">In vigore: <strong>${formatDate(u.effective_date)}</strong></p>` : ''}
${u.url ? `<a href="${escapeHtml(u.url)}" target="_blank" rel="noopener" class="btn btn-sm btn-secondary" style="font-size:0.75rem;">
<svg class="icon" aria-hidden="true" style="width:12px;height:12px;fill:currentColor;margin-right:4px;"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-external-link"></use></svg>
Fonte
</a>` : ''}
${!acked ? `<button class="btn btn-sm btn-primary" onclick="acknowledgeUpdate(${u.id})">
<svg class="icon" aria-hidden="true" style="width:14px;height:14px;fill:currentColor;margin-right:4px;"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>
Prendi in visione
</button>` : ''}
</div>
</div>
</div>`;
});
container.innerHTML = html;
}
async function acknowledgeUpdate(id) {
showModal('Presa Visione', `
<p style="font-size:0.875rem; color:var(--gray-600); margin-bottom:16px;">
Documentare la presa visione dell'aggiornamento normativo. Opzionalmente aggiungere note sull'impatto per la tua organizzazione.
</p>
<div class="form-group">
<label for="ack-notes" class="form-label">Note (opzionale)</label>
<textarea id="ack-notes" class="form-control" rows="3" placeholder="Es. Analizzato con il team di compliance. Nessun impatto diretto rilevato..."></textarea>
</div>
`, `<button class="btn btn-primary" onclick="confirmAck(${id})">Conferma presa visione</button>
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>`);
}
async function confirmAck(id) {
const notes = document.getElementById('ack-notes').value;
try {
const result = await api.request('POST', `/normative/${id}/ack`, { notes });
if (result.success) {
closeModal();
showNotification('Presa visione registrata e documentata.', 'success');
loadUpdates();
loadStats();
} else showNotification(result.message || 'Errore.', 'error');
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
// Init
loadStats();
loadUpdates();
</script>
</body>
</html>
-894
View File
@@ -1,894 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Onboarding - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 — self-hostato da /vendor (MAI CDN). Variante "-bi" della onboarding.html. -->
<link rel="stylesheet" href="vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<style>
body { background: #f5f6f7; }
.wizard-page { min-height: 100vh; display: flex; flex-direction: column; }
.wizard-header { background: #fff; border-bottom: 1px solid #d4d8db; padding: 16px 32px;
display: flex; align-items: center; gap: 12px; }
.wizard-header-logo { width: 36px; height: 36px; background: #06c; border-radius: 6px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.wizard-header-logo svg { width: 20px; height: 20px; fill: #fff; }
.wizard-header-text h1 { font-size: 1.125rem; font-weight: 700; color: #17324d; margin: 0; }
.wizard-header-text span { font-size: .7rem; color: #5a6772; text-transform: uppercase; letter-spacing: .05em; }
.wizard-stepper { background: #fff; border-bottom: 1px solid #d4d8db; padding: 20px 32px; }
.stepper { display: flex; align-items: center; justify-content: center; max-width: 800px; margin: 0 auto; }
.stepper-step { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.stepper-number { width: 32px; height: 32px; border-radius: 50%; background: #e6e9ed; color: #5a6772;
display: flex; align-items: center; justify-content: center; font-size: .8rem; font-weight: 700;
flex-shrink: 0; transition: all .2s; }
.stepper-label { font-size: .8rem; font-weight: 500; color: #5a6772; transition: color .2s; }
.stepper-step.active .stepper-number { background: #06c; color: #fff; box-shadow: 0 0 0 4px rgba(0,102,204,.15); }
.stepper-step.active .stepper-label { color: #06c; font-weight: 600; }
.stepper-step.completed .stepper-number { background: #008758; color: #fff; }
.stepper-step.completed .stepper-label { color: #008758; }
.stepper-connector { width: 40px; height: 2px; background: #d4d8db; margin: 0 8px; flex-shrink: 0; transition: background .2s; }
.stepper-connector.completed { background: #008758; }
.wizard-body { flex: 1; display: flex; justify-content: center; padding: 40px 24px 60px; }
.wizard-content { width: 100%; max-width: 800px; }
.wizard-step { display: none; }
.wizard-step.active { display: block; }
.wizard-step-title { font-size: 1.5rem; font-weight: 700; color: #17324d; margin-bottom: 8px; }
.wizard-step-subtitle { font-size: .9375rem; color: #5a6772; margin-bottom: 32px; line-height: 1.6; }
.form-grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0 20px; }
.form-grid-2 .form-group-full { grid-column: 1 / -1; }
.profile-form-wrapper { max-width: 520px; margin: 0 auto; }
.wizard-loading { display: none; text-align: center; padding: 48px 24px; }
.wizard-loading.active { display: block; }
.wizard-loading-text { font-size: 1rem; font-weight: 600; color: #17324d; margin-bottom: 4px; }
.wizard-loading-subtext { font-size: .8125rem; color: #5a6772; }
.data-source-banner { display: none; }
.data-source-banner.visible { display: block; }
/* Classification result (BI palette) */
.classification-result { padding: 32px; border-radius: 8px; border: 2px solid #d4d8db; text-align: center; margin-bottom: 24px; }
.classification-result.essential { border-color: #06c; background: #e6f0ff; }
.classification-result.important { border-color: #e58a00; background: #fff5e6; }
.classification-result.not-applicable { border-color: #d4d8db; background: #f5f6f7; }
.classification-result.voluntary { border-color: #06c; background: #e6f0ff; }
.classification-result-icon { width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; }
.classification-result.essential .classification-result-icon { background: #06c; color: #fff; }
.classification-result.important .classification-result-icon { background: #e58a00; color: #fff; }
.classification-result.not-applicable .classification-result-icon { background: #b1b9c0; color: #fff; }
.classification-result.voluntary .classification-result-icon { background: #06c; color: #fff; }
.classification-result-icon svg { width: 32px; height: 32px; fill: currentColor; }
.classification-result-label { font-size: 1.5rem; font-weight: 800; margin-bottom: 8px; }
.classification-result.essential .classification-result-label { color: #06c; }
.classification-result.important .classification-result-label { color: #e58a00; }
.classification-result.not-applicable .classification-result-label { color: #5a6772; }
.classification-result.voluntary .classification-result-label { color: #06c; }
.classification-result-desc { font-size: .875rem; color: #5a6772; line-height: 1.6; max-width: 600px; margin: 0 auto; }
.voluntary-section { display: none; margin-bottom: 24px; }
.voluntary-section.visible { display: block; }
.voluntary-card { border: 2px solid #b3d1ff; border-radius: 8px; padding: 24px; display: flex; align-items: flex-start; gap: 16px; background: #fff; }
.voluntary-card.checked { border-color: #06c; background: #e6f0ff; }
.voluntary-card input[type="checkbox"] { width: 20px; height: 20px; flex-shrink: 0; margin-top: 2px; }
.voluntary-card h4 { font-size: .9375rem; font-weight: 700; color: #17324d; margin-bottom: 6px; }
.voluntary-card p { font-size: .8125rem; color: #5a6772; line-height: 1.6; margin: 0; }
.summary-item-label { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #5a6772; margin-bottom: 4px; }
.summary-item-value { font-size: .9375rem; font-weight: 500; color: #17324d; }
.summary-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.wizard-actions { display: flex; justify-content: space-between; align-items: center; padding-top: 32px; border-top: 1px solid #d4d8db; margin-top: 32px; }
.wizard-actions-right { display: flex; gap: 12px; }
.auto-filled .form-control, .auto-filled .form-select, .auto-filled select { border-color: #008758; background: #f0faf4; }
.auto-fill-badge { display: inline-flex; align-items: center; padding: 2px 8px; margin-left: 8px; font-size: .65rem; font-weight: 600; color: #008758; background: #d6f0e2; border-radius: 100px; text-transform: uppercase; vertical-align: middle; }
.ico { width: 1.1em; height: 1.1em; fill: currentColor; vertical-align: -.18em; }
@media (max-width: 768px) {
.wizard-header { padding: 12px 16px; }
.wizard-stepper { padding: 16px; overflow-x: auto; }
.stepper { min-width: 600px; }
.stepper-label { display: none; }
.wizard-body { padding: 24px 16px 40px; }
.form-grid-2, .summary-grid { grid-template-columns: 1fr; }
.wizard-step-title { font-size: 1.25rem; }
}
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="wizard-page">
<!-- Header -->
<div class="wizard-header">
<div class="wizard-header-logo">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/>
<path d="M10 12.5l-2-2-1.41 1.41L10 15.32l5.41-5.41L14 8.5l-4 4z"/>
</svg>
</div>
<div class="wizard-header-text">
<h1>NIS2 Agile</h1>
<span>Configurazione Iniziale</span>
</div>
</div>
<!-- Stepper -->
<div class="wizard-stepper">
<div class="stepper" id="stepper">
<div class="stepper-step active" data-step="1">
<div class="stepper-number">1</div>
<span class="stepper-label">Partita IVA</span>
</div>
<div class="stepper-connector" data-after="1"></div>
<div class="stepper-step" data-step="2">
<div class="stepper-number">2</div>
<span class="stepper-label">Dati Aziendali</span>
</div>
<div class="stepper-connector" data-after="2"></div>
<div class="stepper-step" data-step="3">
<div class="stepper-number">3</div>
<span class="stepper-label">Il Tuo Profilo</span>
</div>
<div class="stepper-connector" data-after="3"></div>
<div class="stepper-step" data-step="4">
<div class="stepper-number">4</div>
<span class="stepper-label">Classificazione</span>
</div>
</div>
</div>
<!-- Wizard Body -->
<div class="wizard-body">
<div class="wizard-content">
<!-- ═══ STEP 1: Partita IVA ═══ -->
<div class="wizard-step active" id="step-1">
<h2 class="wizard-step-title">Inserisci la Partita IVA dell'azienda</h2>
<p class="wizard-step-subtitle">Inserisci la Partita IVA dell'azienda da configurare. I dati verranno recuperati automaticamente dal registro delle imprese.</p>
<div class="card card-bg has-bkg-grey shadow" style="max-width:520px; margin:0 auto;">
<div class="card-body">
<div class="form-group">
<label class="active" for="s1-vat">Partita IVA <span aria-hidden="true">*</span></label>
<div style="display:flex; gap:.5rem; align-items:stretch;">
<span style="display:flex;align-items:center;padding:0 12px;background:#e6e9ed;border:1px solid #b1b9c0;border-radius:4px;font-weight:600;color:#5a6772;">IT</span>
<input type="text" id="s1-vat" class="form-control" placeholder="12345678901" maxlength="11"
style="flex:1;" inputmode="numeric" oninput="this.value=this.value.replace(/\D/g,'')">
</div>
<small class="form-text">11 cifre numeriche senza prefisso IT</small>
</div>
<div id="s1-result" class="d-none" role="status"></div>
<button class="btn btn-primary w-100" id="s1-fetch-btn" onclick="step1FetchCompany()">
<svg class="ico me-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-search"></use></svg>
Cerca Azienda
</button>
</div>
</div>
<div class="wizard-loading" id="s1-loading" aria-live="polite">
<div class="progress-spinner progress-spinner-active size-sm mb-2"><span class="visually-hidden">Caricamento...</span></div>
<div class="wizard-loading-text">Ricerca in corso...</div>
<div class="wizard-loading-subtext">Interrogazione registro imprese</div>
</div>
<div style="text-align:center; margin-top:1.5rem;">
<button class="btn btn-link" onclick="step1ManualEntry()">
Non trovi l'azienda? Inserisci manualmente
</button>
</div>
<div class="wizard-actions" style="margin-top:2rem;">
<div></div>
<div class="wizard-actions-right">
<button class="btn btn-primary" id="s1-continue-btn" onclick="goToStep(2)" style="display:none;">
Continua
<svg class="ico ms-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-right"></use></svg>
</button>
</div>
</div>
</div>
<!-- ═══ STEP 2: Dati Aziendali ═══ -->
<div class="wizard-step" id="step-2">
<h2 class="wizard-step-title">Verifica e Completa i Dati Aziendali</h2>
<p class="wizard-step-subtitle">Controlla i dati qui sotto e completa le informazioni mancanti.</p>
<div class="data-source-banner alert alert-success" id="data-source-banner" role="status">
<span id="data-source-text"></span>
</div>
<div class="card card-bg has-bkg-grey shadow">
<div class="card-body">
<div class="form-grid-2">
<div class="form-group form-group-full">
<label class="active" for="company-name">Ragione Sociale <span aria-hidden="true">*</span></label>
<input type="text" id="company-name" class="form-control" placeholder="Es. Acme S.r.l." required>
</div>
<div class="form-group">
<label class="active" for="vat-number">Partita IVA</label>
<input type="text" id="vat-number" class="form-control" placeholder="IT12345678901" maxlength="16">
</div>
<div class="form-group">
<label class="active" for="fiscal-code">Codice Fiscale</label>
<input type="text" id="fiscal-code" class="form-control" placeholder="Es. 12345678901" maxlength="16">
</div>
<div class="form-group form-group-full">
<label class="active" for="address">Indirizzo</label>
<input type="text" id="address" class="form-control" placeholder="Via Roma, 1">
</div>
<div class="form-group">
<label class="active" for="city">Citta'</label>
<input type="text" id="city" class="form-control" placeholder="Es. Milano">
</div>
<div class="form-group">
<label class="active" for="website">Sito Web</label>
<input type="text" id="website" class="form-control" placeholder="https://www.esempio.it">
</div>
<div class="form-group">
<label class="active" for="company-email">Email Aziendale</label>
<input type="email" id="company-email" class="form-control" placeholder="info@azienda.it">
</div>
<div class="form-group">
<label class="active" for="company-phone">Telefono Aziendale</label>
<input type="text" id="company-phone" class="form-control" placeholder="+39 02 1234567">
</div>
<div class="form-group form-group-full">
<label class="active" for="sector">Settore NIS2 <span aria-hidden="true">*</span></label>
<select id="sector" class="form-select select-pdnd" required>
<option value="">-- Seleziona settore --</option>
<optgroup label="Settori ad Alta Criticita' (Allegato I)">
<option value="energy_electricity">Energia - Elettricita'</option>
<option value="energy_district_heating">Energia - Teleriscaldamento</option>
<option value="energy_oil">Energia - Petrolio</option>
<option value="energy_gas">Energia - Gas</option>
<option value="energy_hydrogen">Energia - Idrogeno</option>
<option value="transport_air">Trasporti - Aereo</option>
<option value="transport_rail">Trasporti - Ferroviario</option>
<option value="transport_water">Trasporti - Marittimo/Fluviale</option>
<option value="transport_road">Trasporti - Stradale</option>
<option value="banking">Banche</option>
<option value="financial_markets">Infrastrutture Mercati Finanziari</option>
<option value="health">Sanita'</option>
<option value="drinking_water">Acqua Potabile</option>
<option value="waste_water">Acque Reflue</option>
<option value="digital_infrastructure">Infrastruttura Digitale</option>
<option value="ict_service_management">Gestione Servizi ICT (B2B)</option>
<option value="public_administration">Pubblica Amministrazione</option>
<option value="space">Spazio</option>
</optgroup>
<optgroup label="Altri Settori Critici (Allegato II)">
<option value="postal_courier">Servizi Postali e Corrieri</option>
<option value="waste_management">Gestione Rifiuti</option>
<option value="chemicals">Fabbricazione Prodotti Chimici</option>
<option value="food">Produzione e Distribuzione Alimentare</option>
<option value="manufacturing_medical">Fabbricazione - Dispositivi Medici</option>
<option value="manufacturing_computers">Fabbricazione - Computer/Elettronica</option>
<option value="manufacturing_electrical">Fabbricazione - Apparecchiature Elettriche</option>
<option value="manufacturing_machinery">Fabbricazione - Macchinari</option>
<option value="manufacturing_vehicles">Fabbricazione - Autoveicoli</option>
<option value="manufacturing_transport">Fabbricazione - Altri Mezzi di Trasporto</option>
<option value="digital_providers">Fornitori Servizi Digitali</option>
<option value="research">Organizzazioni di Ricerca</option>
</optgroup>
<optgroup label="Altro">
<option value="other">Altro Settore</option>
</optgroup>
</select>
</div>
<div class="form-group">
<label class="active" for="employee-count">Numero Dipendenti <span aria-hidden="true">*</span></label>
<input type="number" id="employee-count" class="form-control" placeholder="Es. 150" min="1" required>
</div>
<div class="form-group">
<label class="active" for="annual-turnover">Fatturato Annuo EUR <span aria-hidden="true">*</span></label>
<input type="number" id="annual-turnover" class="form-control" placeholder="Es. 15000000" min="0" required>
</div>
</div>
</div>
</div>
<div class="wizard-actions">
<button class="btn btn-outline-primary" onclick="goToStep(1)">
<svg class="ico me-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-left"></use></svg>
Indietro
</button>
<div class="wizard-actions-right">
<button class="btn btn-primary" onclick="submitStep3()">
Continua
<svg class="ico ms-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-right"></use></svg>
</button>
</div>
</div>
</div>
<!-- ═══ STEP 3: Il Tuo Profilo ═══ -->
<div class="wizard-step" id="step-3">
<h2 class="wizard-step-title">Completa il tuo profilo</h2>
<p class="wizard-step-subtitle">Inserisci le informazioni sul tuo ruolo in azienda.</p>
<div class="profile-form-wrapper">
<div class="card card-bg has-bkg-grey shadow">
<div class="card-body">
<div class="form-group">
<label class="active" for="full-name">Nome Completo</label>
<input type="text" id="full-name" class="form-control" placeholder="Mario Rossi">
</div>
<div class="form-group">
<label class="active" for="role">Ruolo in Azienda <span aria-hidden="true">*</span></label>
<select id="role" class="form-select select-pdnd" required>
<option value="">-- Seleziona ruolo --</option>
<option value="ceo">CEO/Amministratore Delegato</option>
<option value="cto">CTO/Direttore Tecnico</option>
<option value="ciso">CISO/Responsabile Sicurezza</option>
<option value="dpo">DPO/Data Protection Officer</option>
<option value="compliance_manager">Compliance Manager</option>
<option value="it_manager">IT Manager</option>
<option value="legal">Responsabile Legale</option>
<option value="board_member">Membro CDA</option>
<option value="consultant">Consulente Esterno</option>
<option value="other">Altro</option>
</select>
</div>
<div class="form-group mb-0">
<label class="active" for="user-phone">Telefono</label>
<input type="text" id="user-phone" class="form-control" placeholder="+39 333 1234567">
</div>
</div>
</div>
</div>
<div class="wizard-actions">
<button class="btn btn-outline-primary" onclick="goToStep(2)">
<svg class="ico me-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-left"></use></svg>
Indietro
</button>
<div class="wizard-actions-right">
<button class="btn btn-primary" onclick="submitStep4()">
Continua
<svg class="ico ms-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-right"></use></svg>
</button>
</div>
</div>
</div>
<!-- ═══ STEP 4: Classificazione NIS2 e Riepilogo ═══ -->
<div class="wizard-step" id="step-4">
<h2 class="wizard-step-title">Classificazione NIS2</h2>
<p class="wizard-step-subtitle">In base ai dati forniti, ecco la classificazione della tua organizzazione secondo la Direttiva NIS2.</p>
<div class="classification-result" id="classification-result">
<div class="classification-result-icon" id="classification-icon">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/></svg>
</div>
<div class="classification-result-label" id="classification-label"></div>
<div class="classification-result-desc" id="classification-desc"></div>
</div>
<div class="voluntary-section" id="voluntary-section">
<div class="voluntary-card" id="voluntary-card">
<input type="checkbox" id="voluntary-checkbox" onchange="onVoluntaryChange(this.checked)" aria-label="Adesione volontaria alla NIS2">
<div>
<h4>Adesione Volontaria alla NIS2</h4>
<p>
Anche se la tua organizzazione non rientra nell'ambito di applicazione obbligatorio della
Direttiva NIS2, puoi scegliere di aderire volontariamente adottando le misure di sicurezza
previste dalla normativa. Questo consente di migliorare la postura di cybersicurezza e di
prepararsi a possibili future designazioni.
</p>
</div>
</div>
</div>
<div class="card card-bg has-bkg-grey shadow mb-4">
<div class="card-header bg-white"><h3 class="h6 mb-0">Riepilogo Dati Aziendali</h3></div>
<div class="card-body">
<div class="summary-grid">
<div class="summary-item"><div class="summary-item-label">Ragione Sociale</div><div class="summary-item-value" id="sum-company">-</div></div>
<div class="summary-item"><div class="summary-item-label">Partita IVA</div><div class="summary-item-value" id="sum-vat">-</div></div>
<div class="summary-item"><div class="summary-item-label">Settore</div><div class="summary-item-value" id="sum-sector">-</div></div>
<div class="summary-item"><div class="summary-item-label">Tipologia Soggetto</div><div class="summary-item-value" id="sum-entity-type">-</div></div>
<div class="summary-item"><div class="summary-item-label">Dipendenti</div><div class="summary-item-value" id="sum-employees">-</div></div>
<div class="summary-item"><div class="summary-item-label">Fatturato Annuo</div><div class="summary-item-value" id="sum-turnover">-</div></div>
</div>
</div>
</div>
<div class="wizard-actions">
<button class="btn btn-outline-primary" onclick="goToStep(3)">
<svg class="ico me-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-left"></use></svg>
Indietro
</button>
<div class="wizard-actions-right">
<button class="btn btn-primary" id="complete-btn" onclick="completeOnboarding()">
<svg class="ico me-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check-circle"></use></svg>
Completa e Vai alla Dashboard
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }
</script>
<!-- Stessa logica della onboarding.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260613"></script>
<script src="js/common.js?v=20260615"></script>
<script>
// ── Auth check ──────────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
// ── Extend API with onboarding methods ──────────────────────────
api.uploadVisura = async function(file) {
const formData = new FormData();
formData.append('visura', file);
const response = await fetch(this.baseUrl + '/onboarding/upload-visura', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + this.token,
'X-Organization-Id': this.orgId || ''
},
body: formData
});
return response.json();
};
api.fetchCompany = function(vatNumber) {
return this.post('/onboarding/fetch-company', { vat_number: vatNumber });
};
api.completeOnboarding = function(data) {
return this.post('/onboarding/complete', data);
};
// ── Wizard State ────────────────────────────────────────────────
const wizardState = {
currentStep: 1,
method: null,
dataSource: null,
visuraFile: null,
company: {
name: '', vat_number: '', fiscal_code: '', address: '', city: '',
website: '', email: '', phone: '', sector: '', employee_count: '', annual_turnover: ''
},
profile: { full_name: '', role: '', phone: '' },
classification: null,
autoFilledFields: new Set(),
voluntaryCompliance: false
};
function parseEmployeesRange(range) {
if (!range) return '';
if (typeof range === 'number') return range;
const str = String(range).trim();
if (str.endsWith('+')) return parseInt(str);
const parts = str.split('-');
if (parts.length === 2) return parseInt(parts[0]);
return parseInt(str) || '';
}
// ── Step 1: P.IVA Fetch ─────────────────────────────────────────
async function step1FetchCompany() {
const vat = document.getElementById('s1-vat').value.trim();
if (!vat || vat.length !== 11) {
showNotification('Inserisci una Partita IVA valida (11 cifre numeriche).', 'warning');
return;
}
const btn = document.getElementById('s1-fetch-btn');
const loading = document.getElementById('s1-loading');
const resultDiv = document.getElementById('s1-result');
btn.disabled = true;
loading.classList.add('active');
resultDiv.classList.add('d-none');
try {
const result = await api.fetchCompany(vat);
if (result.success && result.data) {
const d = result.data;
wizardState.company.name = d.company_name || d.ragione_sociale || d.name || '';
wizardState.company.vat_number = vat;
wizardState.company.fiscal_code = d.fiscal_code || d.codice_fiscale || '';
wizardState.company.address = d.address || d.indirizzo || '';
wizardState.company.city = d.city || d.citta || '';
wizardState.company.website = d.website || d.sito_web || '';
wizardState.company.email = d.email || d.pec || '';
wizardState.company.phone = d.phone || d.telefono || '';
wizardState.company.sector = d.suggested_sector || d.sector || '';
wizardState.company.employee_count = parseEmployeesRange(d.employees_range) || d.employee_count || d.employees || '';
wizardState.company.annual_turnover = '';
wizardState.dataSource = 'Dati precompilati da CertiSource';
wizardState.autoFilledFields.clear();
if (wizardState.company.name) wizardState.autoFilledFields.add('company-name');
if (wizardState.company.vat_number) wizardState.autoFilledFields.add('vat-number');
if (wizardState.company.fiscal_code) wizardState.autoFilledFields.add('fiscal-code');
if (wizardState.company.address) wizardState.autoFilledFields.add('address');
if (wizardState.company.city) wizardState.autoFilledFields.add('city');
if (wizardState.company.email) wizardState.autoFilledFields.add('company-email');
if (wizardState.company.phone) wizardState.autoFilledFields.add('company-phone');
if (wizardState.company.sector) wizardState.autoFilledFields.add('sector');
if (wizardState.company.employee_count) wizardState.autoFilledFields.add('employee-count');
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>✓ ' + (wizardState.company.name || 'Azienda trovata') + '</strong><br><small>' + (d.address || d.city || '') + '</small>';
document.getElementById('s1-continue-btn').style.display = 'inline-flex';
showNotification('Azienda trovata: ' + (wizardState.company.name || vat), 'success');
} else {
resultDiv.className = 'alert alert-warning';
resultDiv.textContent = 'Azienda non trovata. Puoi inserire i dati manualmente.';
step1ManualEntry(vat);
}
} catch (err) {
showNotification('Errore di connessione al server.', 'error');
} finally {
btn.disabled = false;
loading.classList.remove('active');
}
}
function step1ManualEntry(vat) {
wizardState.company.vat_number = vat || document.getElementById('s1-vat').value.trim();
wizardState.dataSource = null;
wizardState.autoFilledFields.clear();
document.getElementById('s1-continue-btn').style.display = 'inline-flex';
showNotification('Inserisci i dati aziendali manualmente.', 'info');
}
// ── Step navigation ─────────────────────────────────────────────
function goToStep(step) {
wizardState.currentStep = step;
document.querySelectorAll('.wizard-step').forEach(s => s.classList.remove('active'));
const targetStep = document.getElementById('step-' + step);
if (targetStep) targetStep.classList.add('active');
if (step === 2) populateCompanyForm();
if (step === 3) loadUserProfile();
if (step === 4) runClassificationAndSummary();
updateStepper(step);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function updateStepper(currentStep) {
document.querySelectorAll('.stepper-step').forEach(el => {
const s = parseInt(el.dataset.step);
el.classList.remove('active', 'completed');
const numEl = el.querySelector('.stepper-number');
if (s < currentStep) {
el.classList.add('completed');
numEl.innerHTML = '<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
} else if (s === currentStep) {
el.classList.add('active');
numEl.textContent = s;
} else {
numEl.textContent = s;
}
});
document.querySelectorAll('.stepper-connector').forEach(el => {
const afterStep = parseInt(el.dataset.after);
el.classList.toggle('completed', afterStep < currentStep);
});
}
// ── Visura (legacy, kept for API completeness) ──
function handleVisuraFile(file) {
if (file.type !== 'application/pdf') { showNotification('Il file deve essere in formato PDF.', 'warning'); return; }
if (file.size > 10 * 1024 * 1024) { showNotification('Il file non deve superare i 10 MB.', 'warning'); return; }
wizardState.visuraFile = file;
}
async function analyzeVisura() {
if (!wizardState.visuraFile) return;
try {
const result = await api.uploadVisura(wizardState.visuraFile);
if (result.success && result.data) {
const d = result.data;
wizardState.company.name = d.company_name || d.ragione_sociale || d.name || '';
wizardState.company.vat_number = d.vat_number || d.partita_iva || '';
wizardState.company.fiscal_code = d.fiscal_code || d.codice_fiscale || '';
wizardState.company.address = d.address || d.indirizzo || '';
wizardState.company.city = d.city || d.citta || '';
wizardState.company.website = d.website || d.sito_web || '';
wizardState.company.email = d.email || d.pec || '';
wizardState.company.phone = d.phone || d.telefono || '';
wizardState.company.sector = d.suggested_sector || d.sector || '';
wizardState.company.employee_count = parseEmployeesRange(d.employees_range) || d.employee_count || d.employees || '';
wizardState.company.annual_turnover = '';
wizardState.dataSource = 'Dati precompilati dalla visura camerale';
showNotification('Visura analizzata con successo!', 'success');
setTimeout(() => goToStep(2), 600);
} else {
showNotification(result.message || 'Errore nell\'analisi della visura.', 'error');
}
} catch (err) {
showNotification('Errore di connessione al server.', 'error');
}
}
async function fetchCompany() {
const vatInput = document.getElementById('cs-vat');
if (!vatInput) return;
const vat = vatInput.value.trim();
if (!vat) { showNotification('Inserisci la Partita IVA.', 'warning'); return; }
try {
const result = await api.fetchCompany(vat);
if (result.success && result.data) {
const d = result.data;
wizardState.company.name = d.company_name || d.ragione_sociale || d.name || '';
wizardState.company.vat_number = d.vat_number || d.partita_iva || vat;
wizardState.company.sector = d.suggested_sector || d.sector || '';
wizardState.dataSource = 'Dati recuperati da CertiSource';
showNotification('Dati aziendali recuperati con successo!', 'success');
setTimeout(() => goToStep(2), 600);
} else {
showNotification(result.message || 'Impossibile recuperare i dati. Verifica la Partita IVA.', 'error');
}
} catch (err) {
showNotification('Errore di connessione al server.', 'error');
}
}
// ── Step 2: Company Form ────────────────────────────────────────
function populateCompanyForm() {
const c = wizardState.company;
document.getElementById('company-name').value = c.name || '';
document.getElementById('vat-number').value = c.vat_number || '';
document.getElementById('fiscal-code').value = c.fiscal_code || '';
document.getElementById('address').value = c.address || '';
document.getElementById('city').value = c.city || '';
document.getElementById('website').value = c.website || '';
document.getElementById('company-email').value = c.email || '';
document.getElementById('company-phone').value = c.phone || '';
document.getElementById('employee-count').value = c.employee_count || '';
document.getElementById('annual-turnover').value = c.annual_turnover || '';
if (c.sector) {
const sectorSelect = document.getElementById('sector');
if (sectorSelect.querySelector('option[value="' + c.sector + '"]')) sectorSelect.value = c.sector;
}
const banner = document.getElementById('data-source-banner');
const bannerText = document.getElementById('data-source-text');
if (wizardState.dataSource) {
bannerText.textContent = wizardState.dataSource;
banner.classList.add('visible');
} else {
banner.classList.remove('visible');
}
const autoFields = wizardState.autoFilledFields;
document.querySelectorAll('#step-2 .form-control, #step-2 .form-select').forEach(el => {
const wrapper = el.closest('.form-group');
if (!wrapper) return;
wrapper.classList.remove('auto-filled');
const existing = wrapper.querySelector('.auto-fill-badge');
if (existing) existing.remove();
if (autoFields.has(el.id)) {
wrapper.classList.add('auto-filled');
const badge = document.createElement('span');
badge.className = 'auto-fill-badge';
badge.textContent = wizardState.dataSource ? 'auto-compilato' : 'da visura';
const label = wrapper.querySelector('label');
if (label) label.appendChild(badge);
}
});
}
function submitStep3() {
const name = document.getElementById('company-name').value.trim();
const sector = document.getElementById('sector').value;
const employees = document.getElementById('employee-count').value;
const turnover = document.getElementById('annual-turnover').value;
if (!name) { showNotification('Inserisci la ragione sociale.', 'warning'); document.getElementById('company-name').focus(); return; }
if (!sector) { showNotification('Seleziona un settore NIS2.', 'warning'); document.getElementById('sector').focus(); return; }
if (!employees || parseInt(employees) <= 0) { showNotification('Inserisci il numero di dipendenti.', 'warning'); document.getElementById('employee-count').focus(); return; }
if (!turnover || parseInt(turnover) < 0) { showNotification('Inserisci il fatturato annuo.', 'warning'); document.getElementById('annual-turnover').focus(); return; }
wizardState.company.name = name;
wizardState.company.vat_number = document.getElementById('vat-number').value.trim();
wizardState.company.fiscal_code = document.getElementById('fiscal-code').value.trim();
wizardState.company.address = document.getElementById('address').value.trim();
wizardState.company.city = document.getElementById('city').value.trim();
wizardState.company.website = document.getElementById('website').value.trim();
wizardState.company.email = document.getElementById('company-email').value.trim();
wizardState.company.phone = document.getElementById('company-phone').value.trim();
wizardState.company.sector = sector;
wizardState.company.employee_count = parseInt(employees);
wizardState.company.annual_turnover = parseInt(turnover);
goToStep(3);
}
// ── Step 3: User Profile ────────────────────────────────────────
async function loadUserProfile() {
try {
const result = await api.getMe();
if (result.success && result.data) {
const nameInput = document.getElementById('full-name');
if (!nameInput.value && result.data.full_name) {
nameInput.value = result.data.full_name;
wizardState.profile.full_name = result.data.full_name;
}
}
} catch (e) { /* ignore */ }
}
function submitStep4() {
const role = document.getElementById('role').value;
if (!role) { showNotification('Seleziona il tuo ruolo in azienda.', 'warning'); document.getElementById('role').focus(); return; }
wizardState.profile.full_name = document.getElementById('full-name').value.trim();
wizardState.profile.role = role;
wizardState.profile.phone = document.getElementById('user-phone').value.trim();
goToStep(4);
}
// ── Step 4: Classification & Summary ────────────────────────────
function classifyLocally(sector, employees, turnover) {
const annexI = [
'energy_electricity', 'energy_district_heating', 'energy_oil', 'energy_gas',
'energy_hydrogen', 'transport_air', 'transport_rail', 'transport_water',
'transport_road', 'banking', 'financial_markets', 'health', 'drinking_water',
'waste_water', 'digital_infrastructure', 'ict_service_management',
'public_administration', 'space'
];
const annexII = [
'postal_courier', 'waste_management', 'chemicals', 'food',
'manufacturing_medical', 'manufacturing_computers', 'manufacturing_electrical',
'manufacturing_machinery', 'manufacturing_vehicles', 'manufacturing_transport',
'digital_providers', 'research'
];
const isAnnexI = annexI.includes(sector);
const isAnnexII = annexII.includes(sector);
const isLarge = employees >= 250 || turnover >= 50000000;
const isMedium = employees >= 50 || turnover >= 10000000;
if (isAnnexI && isLarge) {
return { classification: 'essential', label: 'Soggetto Essenziale',
description: 'La vostra organizzazione rientra tra i soggetti essenziali ai sensi della Direttiva NIS2, in quanto opera in un settore ad alta criticita\' (Allegato I) e supera le soglie dimensionali per la classificazione come grande impresa. Siete tenuti al rispetto completo degli obblighi di sicurezza con vigilanza ex ante.' };
} else if ((isAnnexI || isAnnexII) && isMedium) {
return { classification: 'important', label: 'Soggetto Importante',
description: 'La vostra organizzazione rientra tra i soggetti importanti ai sensi della Direttiva NIS2. Siete tenuti a rispettare gli obblighi di sicurezza e di notifica degli incidenti, con un regime di vigilanza ex post.' };
} else if (sector === 'other' || (!isAnnexI && !isAnnexII)) {
return { classification: 'not_applicable', label: 'Non Applicabile',
description: 'In base ai dati forniti, la vostra organizzazione non sembra rientrare nell\'ambito di applicazione della Direttiva NIS2. Consigliamo comunque di adottare misure di cybersicurezza adeguate e di verificare con le autorita\' competenti.' };
} else {
return { classification: 'not_applicable', label: 'Sotto le Soglie',
description: 'La vostra organizzazione opera in un settore coperto dalla NIS2 ma non raggiunge le soglie dimensionali minime (50 dipendenti o 10M EUR di fatturato). Potreste comunque essere designati dalle autorita\' nazionali.' };
}
}
function runClassificationAndSummary() {
const c = wizardState.company;
const sector = c.sector;
const employees = parseInt(c.employee_count) || 0;
const turnover = parseInt(c.annual_turnover) || 0;
const result = classifyLocally(sector, employees, turnover);
wizardState.classification = result;
wizardState.voluntaryCompliance = false;
const voluntaryCheckbox = document.getElementById('voluntary-checkbox');
if (voluntaryCheckbox) voluntaryCheckbox.checked = false;
const voluntaryCard = document.getElementById('voluntary-card');
if (voluntaryCard) voluntaryCard.classList.remove('checked');
const voluntarySection = document.getElementById('voluntary-section');
if (result.classification === 'not_applicable') voluntarySection.classList.add('visible');
else voluntarySection.classList.remove('visible');
const container = document.getElementById('classification-result');
const labelEl = document.getElementById('classification-label');
const descEl = document.getElementById('classification-desc');
const iconEl = document.getElementById('classification-icon');
container.className = 'classification-result';
if (result.classification === 'essential') {
container.classList.add('essential');
iconEl.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/><path d="M10 12.5l-2-2-1.41 1.41L10 15.32l5.41-5.41L14 8.5l-4 4z"/></svg>';
} else if (result.classification === 'important') {
container.classList.add('important');
iconEl.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/></svg>';
} else {
container.classList.add('not-applicable');
iconEl.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>';
}
labelEl.textContent = result.label;
descEl.textContent = result.description;
const sectorSelect = document.getElementById('sector');
const sectorOption = sectorSelect.querySelector('option[value="' + c.sector + '"]');
const sectorText = sectorOption ? sectorOption.textContent : c.sector;
document.getElementById('sum-company').textContent = c.name || '-';
document.getElementById('sum-vat').textContent = c.vat_number || '-';
document.getElementById('sum-sector').textContent = sectorText || '-';
document.getElementById('sum-entity-type').textContent = result.label || '-';
document.getElementById('sum-employees').textContent = employees ? employees.toLocaleString('it-IT') : '-';
document.getElementById('sum-turnover').textContent = turnover ? '€ ' + turnover.toLocaleString('it-IT') : '-';
}
function onVoluntaryChange(checked) {
wizardState.voluntaryCompliance = checked;
const card = document.getElementById('voluntary-card');
const container = document.getElementById('classification-result');
const labelEl = document.getElementById('classification-label');
const sumEntityType = document.getElementById('sum-entity-type');
card.classList.toggle('checked', checked);
if (checked) {
container.className = 'classification-result voluntary';
container.querySelector('.classification-result-icon').innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/><path d="M10 12.5l-2-2-1.41 1.41L10 15.32l5.41-5.41L14 8.5l-4 4z"/></svg>';
labelEl.textContent = 'Adesione Volontaria';
document.getElementById('classification-desc').textContent = 'La tua organizzazione aderisce volontariamente alla Direttiva NIS2, adottando le misure di sicurezza previste dalla normativa per migliorare la propria postura di cybersicurezza.';
sumEntityType.textContent = 'Adesione Volontaria';
} else {
container.className = 'classification-result not-applicable';
container.querySelector('.classification-result-icon').innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>';
labelEl.textContent = wizardState.classification.label;
document.getElementById('classification-desc').textContent = wizardState.classification.description;
sumEntityType.textContent = wizardState.classification.label;
}
}
// ── Complete Onboarding ─────────────────────────────────────────
async function completeOnboarding() {
const btn = document.getElementById('complete-btn');
btn.disabled = true;
btn.textContent = 'Completamento in corso...';
const c = wizardState.company;
const p = wizardState.profile;
const payload = {
name: c.name, vat_number: c.vat_number, fiscal_code: c.fiscal_code,
address: c.address, city: c.city, website: c.website,
contact_email: c.email, contact_phone: c.phone, sector: c.sector,
employee_count: parseInt(c.employee_count) || 0,
annual_turnover_eur: parseInt(c.annual_turnover) || 0,
voluntary_compliance: wizardState.voluntaryCompliance ? 1 : 0,
full_name: p.full_name, phone: p.phone, country: 'IT',
};
try {
const result = await api.completeOnboarding(payload);
if (result.success && result.data) {
const orgId = result.data.id || result.data.organization_id;
if (orgId) api.setOrganization(orgId);
showNotification('Configurazione completata con successo!', 'success');
setTimeout(() => {
const dest = api.isConsultant() ? 'companies.html' : 'dashboard.html';
window.location.href = dest;
}, 1000);
} else {
showNotification(result.message || 'Errore nel completamento.', 'error');
btn.disabled = false;
btn.textContent = 'Completa e Vai alla Dashboard';
}
} catch (err) {
showNotification('Errore di connessione al server.', 'error');
btn.disabled = false;
btn.textContent = 'Completa e Vai alla Dashboard';
}
}
// ── Allow P.IVA input to submit with Enter ───────────────────────
document.getElementById('s1-vat').addEventListener('keypress', (e) => {
if (e.key === 'Enter') { e.preventDefault(); step1FetchCompany(); }
});
</script>
</body>
</html>
-981
View File
@@ -1,981 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Policy (BI) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (tabelle/editor/modali) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260613b">
<style>
/* ── Policy Page Styles ──────────────────────────────────── */
.filters-bar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 20px;
}
.filters-bar .form-select {
min-width: 180px;
padding: 8px 12px;
font-size: 0.85rem;
}
.filters-bar .btn {
white-space: nowrap;
}
.policy-actions {
display: flex;
gap: 6px;
}
.policy-actions .btn {
padding: 5px 10px;
font-size: 0.75rem;
}
.tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--gray-200);
margin-bottom: 24px;
}
.tab-btn {
padding: 10px 20px;
font-size: 0.875rem;
font-weight: 600;
color: var(--gray-500);
background: none;
border: none;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all var(--transition-fast);
}
.tab-btn:hover {
color: var(--gray-700);
}
.tab-btn.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.template-card {
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius);
padding: 20px;
cursor: pointer;
transition: all var(--transition-fast);
}
.template-card:hover {
border-color: var(--primary);
box-shadow: var(--card-shadow-hover);
transform: translateY(-2px);
}
.template-card h4 {
font-size: 0.9rem;
color: var(--gray-800);
margin-bottom: 8px;
}
.template-card .template-meta {
font-size: 0.78rem;
color: var(--gray-500);
display: flex;
align-items: center;
gap: 12px;
}
.template-card .template-meta .tag {
font-size: 0.7rem;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
gap: 16px;
}
.detail-header h3 {
font-size: 1.25rem;
color: var(--gray-900);
}
.detail-header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 24px;
}
@media (max-width: 900px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
.policy-content-box {
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius);
padding: 24px;
white-space: pre-wrap;
font-size: 0.875rem;
line-height: 1.7;
color: var(--gray-700);
min-height: 200px;
max-height: 600px;
overflow-y: auto;
}
.meta-card {
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius);
padding: 20px;
}
.meta-card h4 {
font-size: 0.85rem;
font-weight: 600;
color: var(--gray-800);
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--gray-100);
}
.meta-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 0.82rem;
border-bottom: 1px solid var(--gray-50);
}
.meta-row:last-child {
border-bottom: none;
}
.meta-label {
color: var(--gray-500);
font-weight: 500;
}
.meta-value {
color: var(--gray-800);
font-weight: 600;
text-align: right;
}
.ai-preview {
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius);
padding: 20px;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
font-size: 0.85rem;
line-height: 1.6;
margin-top: 12px;
}
.ai-preview-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: var(--primary);
font-weight: 600;
font-size: 0.9rem;
}
.ai-preview-header svg {
width: 20px;
height: 20px;
}
.loading-overlay {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 20px;
flex-direction: column;
gap: 12px;
color: var(--gray-500);
}
.loading-overlay .spinner {
width: 32px;
height: 32px;
border: 3px solid var(--gray-200);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.text-link {
color: var(--primary);
cursor: pointer;
font-weight: 500;
}
.text-link:hover {
text-decoration: underline;
}
.content-header-actions {
display: flex;
gap: 8px;
}
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="policies.title">Gestione Policy</h1>
<div class="content-header-actions">
<button class="btn btn-outline" onclick="openAIGenerateModal()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
Genera con AI
</button>
<button class="btn btn-primary" onclick="openCreateModal()">+ Nuova Policy</button>
</div>
</header>
<div class="content-body" id="page-content">
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('list')">Elenco Policy</button>
<button class="tab-btn" onclick="switchTab('templates')">Template</button>
</div>
<!-- Tab: Lista Policy -->
<div class="tab-content active" id="tab-list">
<div class="filters-bar">
<select class="form-select" id="filter-category" onchange="loadPolicies()" aria-label="Filtra per categoria">
<option value="">Tutte le Categorie</option>
<option value="information_security">Sicurezza Informazioni</option>
<option value="access_control">Controllo Accessi</option>
<option value="incident_response">Risposta Incidenti</option>
<option value="business_continuity">Continuita' Operativa</option>
<option value="supply_chain">Supply Chain</option>
<option value="encryption">Crittografia</option>
<option value="hr_security">Sicurezza HR</option>
<option value="asset_management">Gestione Asset</option>
<option value="network_security">Sicurezza Rete</option>
<option value="vulnerability_management">Gestione Vulnerabilita'</option>
</select>
<select class="form-select" id="filter-status" onchange="loadPolicies()" aria-label="Filtra per stato">
<option value="">Tutti gli Stati</option>
<option value="draft">Bozza</option>
<option value="review">In Revisione</option>
<option value="approved">Approvata</option>
<option value="published">Pubblicata</option>
<option value="archived">Archiviata</option>
</select>
</div>
<div class="card">
<div class="table-container" id="policies-table">
<div class="loading-overlay">
<div class="spinner"></div>
<span>Caricamento policy...</span>
</div>
</div>
</div>
</div>
<!-- Tab: Template -->
<div class="tab-content" id="tab-templates">
<div class="card">
<div class="card-header">
<h3>Template Policy NIS2</h3>
</div>
<div class="card-body" id="templates-grid">
<div class="loading-overlay">
<div class="spinner"></div>
<span>Caricamento template...</span>
</div>
</div>
</div>
</div>
<!-- Detail View (hidden by default) -->
<div id="policy-detail-view" style="display:none;"></div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260613"></script>
<script src="/js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260615"></script>
<script src="/js/i18n.js?v=20260615"></script>
<script src="/js/help.js?v=20260615"></script>
<script>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
HelpSystem.init();
// ── Labels ──────────────────────────────────────────────
const CATEGORY_LABELS = {
information_security: 'Sicurezza Informazioni',
access_control: 'Controllo Accessi',
incident_response: 'Risposta Incidenti',
business_continuity: 'Continuita\' Operativa',
supply_chain: 'Supply Chain',
encryption: 'Crittografia',
hr_security: 'Sicurezza HR',
asset_management: 'Gestione Asset',
network_security: 'Sicurezza Rete',
vulnerability_management: 'Gestione Vulnerabilita\''
};
const STATUS_LABELS = {
draft: 'Bozza',
review: 'In Revisione',
approved: 'Approvata',
published: 'Pubblicata',
archived: 'Archiviata'
};
const STATUS_BADGE_CLASS = {
draft: 'badge-neutral',
review: 'badge-info',
approved: 'badge-success',
published: 'badge-primary',
archived: 'badge-neutral'
};
// ── State ───────────────────────────────────────────────
let currentView = 'list'; // 'list' | 'detail'
let policiesData = [];
// ── Tab switching ───────────────────────────────────────
function switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
if (tab === 'list') {
document.querySelectorAll('.tab-btn')[0].classList.add('active');
document.getElementById('tab-list').classList.add('active');
} else if (tab === 'templates') {
document.querySelectorAll('.tab-btn')[1].classList.add('active');
document.getElementById('tab-templates').classList.add('active');
loadTemplates();
}
// Hide detail view when switching tabs
document.getElementById('policy-detail-view').style.display = 'none';
document.querySelector('.tabs').style.display = '';
document.getElementById('tab-list').style.display = '';
document.getElementById('tab-templates').style.display = '';
currentView = 'list';
}
// ── Load Policies ───────────────────────────────────────
async function loadPolicies() {
const params = {};
const category = document.getElementById('filter-category').value;
const status = document.getElementById('filter-status').value;
if (category) params.category = category;
if (status) params.status = status;
const container = document.getElementById('policies-table');
container.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento policy...</span></div>';
try {
const result = await api.listPolicies(params);
if (result.success) {
policiesData = result.data || [];
renderPoliciesTable(policiesData);
} else {
container.innerHTML = '<div class="empty-state"><h4>Errore nel caricamento</h4><p>' + escapeHtml(result.message || 'Errore sconosciuto') + '</p></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4><p>Impossibile caricare le policy.</p></div>';
}
}
function renderPoliciesTable(policies) {
const container = document.getElementById('policies-table');
if (!policies || policies.length === 0) {
container.innerHTML = `
<div class="empty-state" style="padding:40px 20px;">
<svg viewBox="0 0 20 20" fill="currentColor" style="width:48px;height:48px;color:var(--gray-300);">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/>
</svg>
<h4>Nessuna policy trovata</h4>
<p>Crea la tua prima policy o utilizza un template per iniziare.</p>
</div>`;
return;
}
let html = `
<table>
<thead>
<tr>
<th>Titolo</th>
<th>Categoria</th>
<th>Art. NIS2</th>
<th>Versione</th>
<th>Stato</th>
<th>Prossima Revisione</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>`;
policies.forEach(p => {
const catLabel = CATEGORY_LABELS[p.category] || p.category || '-';
const statusLabel = STATUS_LABELS[p.status] || p.status || 'Bozza';
const badgeClass = STATUS_BADGE_CLASS[p.status] || 'badge-neutral';
html += `
<tr>
<td>
<span class="text-link" onclick="viewPolicy(${p.id})">${escapeHtml(p.title)}</span>
</td>
<td>${escapeHtml(catLabel)}</td>
<td>${escapeHtml(p.nis2_article || '-')}</td>
<td>${escapeHtml(p.version || '1.0')}</td>
<td><span class="badge ${badgeClass}">${escapeHtml(statusLabel)}</span></td>
<td>${formatDate(p.next_review_date)}</td>
<td>
<div class="policy-actions">
<button class="btn btn-sm btn-ghost" onclick="viewPolicy(${p.id})" title="Visualizza">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
</button>
<button class="btn btn-sm btn-ghost" onclick="openEditModal(${p.id})" title="Modifica">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
</button>
${p.status === 'draft' || p.status === 'review' ? `
<button class="btn btn-sm btn-success" onclick="approvePolicy(${p.id})" title="Approva">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</button>` : ''}
<button class="btn btn-sm btn-ghost" onclick="confirmDeletePolicy(${p.id}, '${escapeHtml(p.title).replace(/'/g, "\\'")}')" title="Elimina" style="color:var(--danger);">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
</button>
</div>
</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
// ── View Policy Detail ──────────────────────────────────
async function viewPolicy(id) {
// Hide tabs and list
document.querySelector('.tabs').style.display = 'none';
document.getElementById('tab-list').classList.remove('active');
document.getElementById('tab-templates').classList.remove('active');
const detailView = document.getElementById('policy-detail-view');
detailView.style.display = 'block';
detailView.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento dettagli...</span></div>';
currentView = 'detail';
try {
const result = await api.getPolicy(id);
if (result.success && result.data) {
renderPolicyDetail(result.data);
} else {
detailView.innerHTML = '<div class="empty-state"><h4>Policy non trovata</h4></div>';
}
} catch (e) {
detailView.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
}
}
function renderPolicyDetail(policy) {
const detailView = document.getElementById('policy-detail-view');
const catLabel = CATEGORY_LABELS[policy.category] || policy.category || '-';
const statusLabel = STATUS_LABELS[policy.status] || policy.status || 'Bozza';
const badgeClass = STATUS_BADGE_CLASS[policy.status] || 'badge-neutral';
detailView.innerHTML = `
<div class="detail-header">
<div>
<div style="margin-bottom:8px;">
<span class="text-link" onclick="backToList()" style="font-size:0.82rem;">
&larr; Torna all'elenco
</span>
</div>
<h3>${escapeHtml(policy.title)}</h3>
<div style="margin-top:8px;">
<span class="badge ${badgeClass}">${escapeHtml(statusLabel)}</span>
${policy.ai_generated ? '<span class="badge badge-info" style="margin-left:6px;">AI Generata</span>' : ''}
</div>
${policy.ai_generated && (policy.status === 'draft' || policy.status === 'review') ? `
<div id="ai-draft-warning-${policy.id}" style="background:#fff7ed; border:1px solid #fed7aa; border-radius:var(--border-radius); padding:12px 16px; margin-top:12px; display:flex; gap:10px; align-items:flex-start; position:relative;">
<svg viewBox="0 0 20 20" fill="#c2410c" width="18" height="18" style="flex-shrink:0; margin-top:2px;"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
<div style="flex:1;">
<strong style="color:#c2410c; font-size:0.875rem;">Bozza AI — Revisione obbligatoria prima dell'approvazione</strong>
<p style="font-size:0.78rem; color:#92400e; margin:4px 0 0;">Questo documento e' stato generato automaticamente da un sistema AI. Prima di approvarlo, verificare che sia conforme alle procedure interne, al quadro normativo applicabile e alla realta' specifica dell'organizzazione. L'AI puo' produrre contenuti imprecisi o incompleti.</p>
</div>
<button onclick="document.getElementById('ai-draft-warning-${policy.id}').remove();" title="Chiudi" aria-label="Chiudi avviso"
style="flex-shrink:0;background:none;border:none;cursor:pointer;color:#c2410c;padding:2px;line-height:1;opacity:0.7;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.7">
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</button>
</div>` : ''}
</div>
<div class="detail-header-actions">
${policy.status === 'draft' || policy.status === 'review' ? `
<button class="btn btn-success" onclick="approvePolicy(${policy.id})">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
Approva
</button>` : ''}
<button class="btn btn-secondary" onclick="openEditModal(${policy.id})">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
Modifica
</button>
</div>
</div>
<div class="detail-grid">
<div>
<div class="card">
<div class="card-header">
<h3>Contenuto Policy</h3>
</div>
<div class="card-body">
<div class="policy-content-box">${escapeHtml(policy.content || 'Nessun contenuto disponibile.')}</div>
</div>
</div>
</div>
<div>
<div class="meta-card">
<h4>Informazioni</h4>
<div class="meta-row">
<span class="meta-label">Categoria</span>
<span class="meta-value">${escapeHtml(catLabel)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Art. NIS2</span>
<span class="meta-value">${escapeHtml(policy.nis2_article || '-')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Versione</span>
<span class="meta-value">${escapeHtml(policy.version || '1.0')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Stato</span>
<span class="meta-value"><span class="badge ${badgeClass}">${escapeHtml(statusLabel)}</span></span>
</div>
<div class="meta-row">
<span class="meta-label">Creata il</span>
<span class="meta-value">${formatDate(policy.created_at)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Aggiornata il</span>
<span class="meta-value">${formatDate(policy.updated_at)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Prossima Revisione</span>
<span class="meta-value">${formatDate(policy.next_review_date)}</span>
</div>
${policy.approved_by_name ? `
<div class="meta-row">
<span class="meta-label">Approvata da</span>
<span class="meta-value">${escapeHtml(policy.approved_by_name)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Approvata il</span>
<span class="meta-value">${formatDate(policy.approved_at)}</span>
</div>` : ''}
</div>
</div>
</div>
`;
}
function backToList() {
document.getElementById('policy-detail-view').style.display = 'none';
document.querySelector('.tabs').style.display = '';
switchTab('list');
currentView = 'list';
}
// ── Create / Edit Modal ─────────────────────────────────
function openCreateModal() {
showPolicyFormModal(null);
}
async function openEditModal(id) {
try {
const result = await api.getPolicy(id);
if (result.success && result.data) {
showPolicyFormModal(result.data);
} else {
showNotification('Impossibile caricare la policy.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
function showPolicyFormModal(policy) {
const isEdit = !!policy;
const title = isEdit ? 'Modifica Policy' : 'Nuova Policy';
const categoryOptions = Object.entries(CATEGORY_LABELS).map(([val, label]) =>
`<option value="${val}" ${policy && policy.category === val ? 'selected' : ''}>${label}</option>`
).join('');
const content = `
<form id="policy-form" onsubmit="return false;">
<div class="form-group">
<label for="form-title" class="form-label">Titolo <span class="required">*</span></label>
<input type="text" class="form-input" id="form-title" value="${isEdit ? escapeHtml(policy.title) : ''}" required placeholder="Inserisci il titolo della policy">
</div>
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="form-group">
<label for="form-category" class="form-label">Categoria <span class="required">*</span></label>
<select class="form-select" id="form-category" required>
<option value="">Seleziona...</option>
${categoryOptions}
</select>
</div>
<div class="form-group">
<label for="form-nis2-article" class="form-label">Articolo NIS2</label>
<input type="text" class="form-input" id="form-nis2-article" value="${isEdit && policy.nis2_article ? escapeHtml(policy.nis2_article) : ''}" placeholder="es. 21.2.a">
</div>
</div>
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="form-group">
<label for="form-version" class="form-label">Versione</label>
<input type="text" class="form-input" id="form-version" value="${isEdit && policy.version ? escapeHtml(policy.version) : '1.0'}" placeholder="1.0">
</div>
<div class="form-group">
<label for="form-review-date" class="form-label">Prossima Revisione</label>
<input type="date" class="form-input" id="form-review-date" value="${isEdit && policy.next_review_date ? policy.next_review_date.substring(0, 10) : ''}">
</div>
</div>
<div class="form-group">
<label for="form-content" class="form-label">Contenuto</label>
<textarea class="form-textarea" id="form-content" rows="12" placeholder="Inserisci il contenuto della policy...&#10;&#10;Puoi strutturare il documento con sezioni:&#10;1. Scopo&#10;2. Ambito&#10;3. Responsabilita'&#10;4. Procedure&#10;5. Revisione">${isEdit && policy.content ? escapeHtml(policy.content) : ''}</textarea>
</div>
</form>
`;
showModal(title, content, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="savePolicy(${isEdit ? policy.id : 'null'})">${isEdit ? 'Salva Modifiche' : 'Crea Policy'}</button>
`
});
}
async function savePolicy(id) {
const title = document.getElementById('form-title').value.trim();
const category = document.getElementById('form-category').value;
if (!title) {
showNotification('Il titolo e\' obbligatorio.', 'warning');
return;
}
if (!category) {
showNotification('La categoria e\' obbligatoria.', 'warning');
return;
}
const data = {
title: title,
category: category,
nis2_article: document.getElementById('form-nis2-article').value.trim(),
version: document.getElementById('form-version').value.trim() || '1.0',
content: document.getElementById('form-content').value,
next_review_date: document.getElementById('form-review-date').value || null,
};
const btn = document.querySelector('#modal-overlay .btn-primary');
setButtonLoading(btn, true);
try {
let result;
if (id) {
result = await api.updatePolicy(id, data);
} else {
result = await api.createPolicy(data);
}
if (result.success) {
closeModal();
showNotification(id ? 'Policy aggiornata con successo.' : 'Policy creata con successo.', 'success');
loadPolicies();
if (currentView === 'detail' && id) {
viewPolicy(id);
}
} else {
setButtonLoading(btn, false);
showNotification(result.message || 'Errore nel salvataggio.', 'error');
}
} catch (e) {
setButtonLoading(btn, false);
showNotification('Errore di connessione.', 'error');
}
}
// ── Approve Policy ──────────────────────────────────────
async function approvePolicy(id) {
showModal('Conferma Approvazione', `
<p>Sei sicuro di voler approvare questa policy?</p>
<p style="margin-top:8px;font-size:0.85rem;color:var(--gray-500);">Lo stato verra' modificato in "Approvata" e non sara' piu' possibile annullare l'operazione.</p>
`, {
size: 'sm',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-success" onclick="doApprovePolicy(${id})">Approva</button>
`
});
}
async function doApprovePolicy(id) {
closeModal();
try {
const result = await api.approvePolicy(id);
if (result.success) {
showNotification('Policy approvata con successo.', 'success');
loadPolicies();
if (currentView === 'detail') {
viewPolicy(id);
}
} else {
showNotification(result.message || 'Errore nell\'approvazione.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Delete Policy ───────────────────────────────────────
function confirmDeletePolicy(id, title) {
showModal('Conferma Eliminazione', `
<p>Sei sicuro di voler eliminare la policy <strong>${title}</strong>?</p>
<p style="margin-top:8px;font-size:0.85rem;color:var(--danger);">Questa azione e' irreversibile.</p>
`, {
size: 'sm',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-danger" onclick="doDeletePolicy(${id})">Elimina</button>
`
});
}
async function doDeletePolicy(id) {
closeModal();
try {
const result = await api.del(`/policies/${id}`);
if (result.success) {
showNotification('Policy eliminata.', 'success');
loadPolicies();
if (currentView === 'detail') {
backToList();
}
} else {
showNotification(result.message || 'Errore nell\'eliminazione.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── AI Generate ─────────────────────────────────────────
function openAIGenerateModal() {
const categoryOptions = Object.entries(CATEGORY_LABELS).map(([val, label]) =>
`<option value="${val}">${label}</option>`
).join('');
showModal('Genera Policy con AI', `
<p style="margin-bottom:16px;color:var(--gray-600);font-size:0.9rem;">
Seleziona la categoria per generare automaticamente una bozza di policy tramite intelligenza artificiale.
Il contenuto generato potra' essere rivisto e modificato prima della pubblicazione.
</p>
<div class="form-group">
<label for="ai-category" class="form-label">Categoria Policy <span class="required">*</span></label>
<select class="form-select" id="ai-category">
${categoryOptions}
</select>
</div>
<div id="ai-preview-container" style="display:none;"></div>
`, {
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" id="ai-generate-btn" onclick="doAIGenerate()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
Genera
</button>
`
});
}
let aiGeneratedData = null;
async function doAIGenerate() {
const category = document.getElementById('ai-category').value;
const btn = document.getElementById('ai-generate-btn');
const previewContainer = document.getElementById('ai-preview-container');
btn.disabled = true;
btn.innerHTML = '<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0 auto;"></div> Generazione...';
try {
const result = await api.aiGeneratePolicy(category);
if (result.success && result.data) {
aiGeneratedData = result.data;
previewContainer.style.display = 'block';
previewContainer.innerHTML = `
<div class="ai-preview-header">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
Policy Generata: ${escapeHtml(aiGeneratedData.title || '')}
</div>
<div class="ai-preview">${escapeHtml(aiGeneratedData.content || '')}</div>
`;
// Change footer buttons
const footer = btn.closest('.modal-footer');
footer.innerHTML = `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="saveAIGeneratedPolicy()">Salva come Bozza</button>
`;
} else {
showNotification(result.message || 'Errore nella generazione AI.', 'error');
btn.disabled = false;
btn.textContent = 'Genera';
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
btn.disabled = false;
btn.textContent = 'Genera';
}
}
async function saveAIGeneratedPolicy() {
if (!aiGeneratedData) return;
const data = {
title: aiGeneratedData.title || 'Policy Generata AI',
category: aiGeneratedData.category || document.getElementById('ai-category')?.value || 'information_security',
nis2_article: aiGeneratedData.nis2_article || '',
content: aiGeneratedData.content || '',
version: '1.0',
ai_generated: 1,
};
const btn = document.querySelector('#modal-overlay .btn-primary');
setButtonLoading(btn, true);
try {
const result = await api.createPolicy(data);
if (result.success) {
closeModal();
showNotification('Policy AI salvata come bozza.', 'success');
aiGeneratedData = null;
loadPolicies();
} else {
setButtonLoading(btn, false);
showNotification(result.message || 'Errore nel salvataggio.', 'error');
}
} catch (e) {
setButtonLoading(btn, false);
showNotification('Errore di connessione.', 'error');
}
}
// ── Templates ───────────────────────────────────────────
async function loadTemplates() {
const container = document.getElementById('templates-grid');
container.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento template...</span></div>';
try {
const result = await api.getPolicyTemplates();
if (result.success && result.data) {
renderTemplates(result.data);
} else {
container.innerHTML = '<div class="empty-state"><h4>Nessun template disponibile</h4></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
}
}
function renderTemplates(templates) {
const container = document.getElementById('templates-grid');
if (!templates || templates.length === 0) {
container.innerHTML = '<div class="empty-state"><h4>Nessun template disponibile</h4></div>';
return;
}
let html = '<div class="template-grid">';
templates.forEach(t => {
const catLabel = CATEGORY_LABELS[t.category] || t.category;
html += `
<div class="template-card" onclick="createFromTemplate('${escapeHtml(t.category)}', '${escapeHtml(t.title).replace(/'/g, "\\'")}', '${escapeHtml(t.nis2_article || '').replace(/'/g, "\\'")}')">
<h4>${escapeHtml(t.title)}</h4>
<div class="template-meta">
<span class="tag">${escapeHtml(catLabel)}</span>
${t.nis2_article ? `<span style="color:var(--gray-400);">Art. ${escapeHtml(t.nis2_article)}</span>` : ''}
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
function createFromTemplate(category, title, nis2Article) {
showPolicyFormModal({
title: title,
category: category,
nis2_article: nis2Article,
version: '1.0',
content: '',
});
}
// ── Init ────────────────────────────────────────────────
loadPolicies();
</script>
</body>
</html>
-607
View File
@@ -1,607 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Registrazione - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 — self-hostato da /vendor (MAI CDN). Variante "-bi" della register.html. -->
<link rel="stylesheet" href="vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<style>
body { min-height: 100vh; display: flex; align-items: center; background: #f5f6f7; padding: 24px 0; }
.auth-wrap { width: 100%; max-width: 560px; margin: 0 auto; padding: 24px; }
.auth-brand { text-align: center; margin-bottom: 20px; }
.auth-brand .brand-name { font-weight: 700; font-size: 1.5rem; color: #06c; }
.auth-brand .brand-name span { color: #17324d; }
.auth-brand .brand-sub { color: #5a6772; font-size: .9rem; margin-top: 4px; }
/* Step indicator (BI Blu Italia) */
.step-indicator { display: flex; align-items: center; justify-content: center; margin-bottom: 1.5rem; }
.step-item { display: flex; flex-direction: column; align-items: center; }
.step-circle { width: 32px; height: 32px; border-radius: 50%; border: 2px solid #d4d8db;
display: flex; align-items: center; justify-content: center; font-size: .8rem; font-weight: 700;
color: #5a6772; background: #fff; transition: all .25s; }
.step-item.active .step-circle { border-color: #06c; color: #06c; background: #e6f0ff; }
.step-item.done .step-circle { border-color: #008758; background: #008758; color: #fff; }
.step-label { font-size: .68rem; color: #5a6772; margin-top: 4px; white-space: nowrap; }
.step-item.active .step-label { color: #06c; font-weight: 600; }
.step-connector { width: 48px; height: 2px; background: #d4d8db; margin-bottom: 18px; transition: background .25s; }
.step-connector.done { background: #008758; }
/* Role cards (BI palette) */
.role-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; margin-bottom: 1.25rem; }
.role-card { border: 2px solid #d4d8db; border-radius: 8px; padding: 1rem .875rem; cursor: pointer;
transition: all .2s; background: #fff; text-align: left; }
.role-card:hover { border-color: #06c; background: #e6f0ff; }
.role-card:focus-visible { outline: 2px solid #06c; outline-offset: 2px; }
.role-card.selected { border-color: #06c; background: #e6f0ff; box-shadow: 0 0 0 3px rgba(0,102,204,.15); }
.role-card-icon { width: 36px; height: 36px; border-radius: 8px; background: rgba(0,102,204,.12); color: #06c;
display: flex; align-items: center; justify-content: center; margin-bottom: .6rem; }
.role-card-title { font-weight: 700; font-size: .88rem; color: #17324d; margin-bottom: .2rem; }
.role-card-desc { font-size: .72rem; color: #5a6772; line-height: 1.45; }
.invite-section { border: 1px dashed #d4d8db; border-radius: 8px; padding: .875rem 1rem; margin-bottom: 1rem; }
.invite-section summary { cursor: pointer; font-size: .82rem; font-weight: 600; color: #5a6772; }
.invite-section[open] summary { color: #06c; }
.pw-wrap { position: relative; }
.pw-wrap .form-control { padding-right: 44px; }
.pw-toggle { position: absolute; right: 8px; top: 8px; background: none; border: none; color: #5a6772;
cursor: pointer; padding: 6px; }
.lookup-status { font-size: .75rem; margin-top: .35rem; min-height: 1.1rem; display: flex; align-items: center; gap: .35rem; }
.lookup-status.ok { color: #008758; }
.lookup-status.err { color: #d9364f; }
.lookup-status.loading { color: #5a6772; }
.success-body { text-align: center; padding: .5rem 0 1rem; }
.success-icon { width: 64px; height: 64px; border-radius: 50%; background: #008758;
display: flex; align-items: center; justify-content: center; margin: 0 auto 1.25rem; color: #fff; }
/* Steps display */
#step-0, #step-1, #step-2 { display: none; }
#step-0 { display: block; }
.ico { width: 1em; height: 1em; fill: currentColor; vertical-align: -.125em; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<main class="auth-wrap">
<div class="auth-brand">
<h1 class="brand-name">NIS2 <span>Agile</span></h1>
<div class="brand-sub" id="auth-subtitle">Crea il tuo account</div>
</div>
<div class="card card-bg has-bkg-grey shadow">
<div class="card-body">
<!-- Step indicator -->
<div class="step-indicator" id="step-indicator">
<div class="step-item active" id="si-0">
<div class="step-circle">1</div>
<div class="step-label">Ruolo</div>
</div>
<div class="step-connector" id="conn-0"></div>
<div class="step-item" id="si-1">
<div class="step-circle">2</div>
<div class="step-label">Dati</div>
</div>
<div class="step-connector" id="conn-1"></div>
<div class="step-item" id="si-2">
<div class="step-circle">3</div>
<div class="step-label">Fatto</div>
</div>
</div>
<div class="alert alert-danger d-none" id="register-error" role="alert"></div>
<!-- STEP 0: Ruolo + Codice Invito -->
<div id="step-0">
<div class="role-grid" role="radiogroup" aria-label="Seleziona il tuo ruolo">
<div class="role-card" id="card-compliance_manager" role="radio" aria-checked="false" tabindex="0"
onclick="selectRole('compliance_manager')" onkeydown="cardKey(event,'compliance_manager')">
<div class="role-card-icon"><svg class="ico" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-locked"></use></svg></div>
<div class="role-card-title">CISO / Compliance Manager</div>
<div class="role-card-desc">Responsabile sicurezza e conformità NIS2 dell'organizzazione</div>
</div>
<div class="role-card" id="card-org_admin" role="radio" aria-checked="false" tabindex="0"
onclick="selectRole('org_admin')" onkeydown="cardKey(event,'org_admin')">
<div class="role-card-icon"><svg class="ico" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg></div>
<div class="role-card-title">Legale Rappresentante / Admin</div>
<div class="role-card-desc">Amministratore e legale rappresentante dell'organizzazione</div>
</div>
<div class="role-card" id="card-consultant" role="radio" aria-checked="false" tabindex="0"
onclick="selectRole('consultant')" onkeydown="cardKey(event,'consultant')">
<div class="role-card-icon"><svg class="ico" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg></div>
<div class="role-card-title">Consulente Cybersecurity</div>
<div class="role-card-desc">Gestisco la compliance NIS2 per più aziende clienti</div>
</div>
<div class="role-card" id="card-auditor" role="radio" aria-checked="false" tabindex="0"
onclick="selectRole('auditor')" onkeydown="cardKey(event,'auditor')">
<div class="role-card-icon"><svg class="ico" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-search"></use></svg></div>
<div class="role-card-title">Auditor / Revisore</div>
<div class="role-card-desc">Verifico e valuto l'implementazione dei controlli di sicurezza</div>
</div>
<div class="role-card" id="card-board_member" role="radio" aria-checked="false" tabindex="0"
onclick="selectRole('board_member')" onkeydown="cardKey(event,'board_member')" style="grid-column: span 2;">
<div class="role-card-icon"><svg class="ico" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-key"></use></svg></div>
<div class="role-card-title">Board Member / DPO</div>
<div class="role-card-desc">Membro del consiglio o Responsabile della Protezione dei Dati con visibilità strategica</div>
</div>
</div>
<details class="invite-section" id="invite-details">
<summary>Ho un codice di invito B2B</summary>
<div class="form-group mt-2">
<label for="invite-code">Codice Invito</label>
<input type="text" id="invite-code" class="form-control"
placeholder="es. inv_a1b2c3d4e5f6..." autocomplete="off"
oninput="validateInvite(this.value)">
<div class="lookup-status" id="invite-status" aria-live="polite"></div>
</div>
</details>
<button class="btn btn-primary w-100 mt-2" id="btn-next-0" onclick="goToStep1()" disabled>
Continua
<svg class="ico ms-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-right"></use></svg>
</button>
</div>
<!-- STEP 1: Dati account -->
<div id="step-1">
<div id="invite-welcome" class="alert alert-info" style="display:none;">
<div id="invite-welcome-name" style="font-weight:700;"></div>
<div id="invite-welcome-plan" style="font-size:.8rem;"></div>
<p class="mb-0 mt-1" style="font-size:.85rem;">I tuoi dati sono stati pre-compilati dall'invito. <strong>Scegli solo la password</strong> per accedere alla piattaforma.</p>
</div>
<form id="register-form" novalidate>
<div class="row">
<div class="form-group col">
<label for="firstname" class="active">Nome <span aria-hidden="true">*</span></label>
<input type="text" id="firstname" name="firstname" class="form-control"
autocomplete="given-name" required>
</div>
<div class="form-group col">
<label for="lastname" class="active">Cognome <span aria-hidden="true">*</span></label>
<input type="text" id="lastname" name="lastname" class="form-control"
autocomplete="family-name" required>
</div>
</div>
<div class="form-group">
<label for="email" class="active">Indirizzo Email <span aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" class="form-control"
autocomplete="email" required>
</div>
<div class="form-group" id="piva-group">
<label for="piva" class="active">Partita IVA Azienda <span aria-hidden="true">*</span></label>
<input type="text" id="piva" name="piva" class="form-control"
placeholder="12345678901" maxlength="11" inputmode="numeric"
oninput="lookupPiva(this.value)">
<div class="lookup-status" id="piva-status" aria-live="polite"></div>
</div>
<div class="form-group">
<label for="password" class="active">Password <span aria-hidden="true">*</span></label>
<div class="pw-wrap">
<input type="password" id="password" name="password" class="form-control"
placeholder="Minimo 8 caratteri" autocomplete="new-password" required
aria-describedby="ps-text">
<button type="button" class="pw-toggle" tabindex="-1"
onclick="togglePw('password', this)" aria-label="Mostra o nascondi la password">
<svg class="icon icon-sm" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-password-visible"></use></svg>
</button>
</div>
<div class="progress mt-2" style="height:6px;">
<div class="progress-bar" id="ps-bar" role="progressbar" style="width:0%;" aria-hidden="true"></div>
</div>
<div class="form-text" id="ps-text" aria-live="polite"></div>
</div>
<div class="form-group">
<label for="password-confirm" class="active">Conferma Password <span aria-hidden="true">*</span></label>
<div class="pw-wrap">
<input type="password" id="password-confirm" name="password-confirm" class="form-control"
placeholder="Ripeti la password" autocomplete="new-password" required>
<button type="button" class="pw-toggle" tabindex="-1"
onclick="togglePw('password-confirm', this)" aria-label="Mostra o nascondi la password">
<svg class="icon icon-sm" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-password-visible"></use></svg>
</button>
</div>
</div>
<div class="d-flex" style="gap:.75rem;">
<button type="button" class="btn btn-outline-primary" onclick="goToStep0()" aria-label="Torna allo step precedente">
<svg class="ico" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-left"></use></svg>
</button>
<button type="submit" class="btn btn-primary flex-grow-1" id="register-btn">
Crea Account
</button>
</div>
</form>
</div>
<!-- STEP 2: Successo -->
<div id="step-2">
<div class="success-body">
<div class="success-icon">
<svg class="icon icon-white" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>
</div>
<h2 class="h4" id="success-title">Account creato!</h2>
<p id="success-desc" class="text-muted">
Il tuo account NIS2 Agile è pronto. Stai per essere reindirizzato...
</p>
<button class="btn btn-primary w-100" id="success-btn" onclick="goToDashboard()">
Entra nella piattaforma
<svg class="ico ms-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-right"></use></svg>
</button>
</div>
</div>
</div>
</div>
<p class="text-center mt-3 mb-0">
Hai già un account? <a href="login-bi.html">Accedi</a>
</p>
<p class="text-center small text-muted">
Registrandoti accetti i
<a href="https://agentai.agile.software/terms" target="_blank" rel="noopener">Termini di Servizio</a>
e la <a href="https://agentai.agile.software/privacy" target="_blank" rel="noopener">Privacy Policy</a>.
</p>
</main>
<script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }
</script>
<!-- Stessa logica della register.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260613"></script>
<script src="js/common.js?v=20260615"></script>
<script>
if (api.isAuthenticated()) { window.location.href = 'dashboard-bi.html'; }
// --- State ---
let selectedRole = null;
let inviteValid = false;
let inviteToken = null;
let inviteRecipient = null;
let isReducedReg = false;
let pivaData = null;
let pivaLookupTimer = null;
let successRedirect = 'onboarding-bi.html';
const params = new URLSearchParams(window.location.search);
// --- Role Selection ---
function selectRole(role) {
selectedRole = role;
document.querySelectorAll('.role-card').forEach(c => { c.classList.remove('selected'); c.setAttribute('aria-checked','false'); });
const card = document.getElementById('card-' + role);
if (card) { card.classList.add('selected'); card.setAttribute('aria-checked','true'); }
document.getElementById('btn-next-0').disabled = false;
const pivaGroup = document.getElementById('piva-group');
pivaGroup.style.display = (role === 'consultant') ? 'none' : 'block';
}
function cardKey(e, role) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectRole(role); }
}
// Pre-fill from URL after DOM ready
(function() {
const r = params.get('role');
if (r && document.getElementById('card-' + r)) selectRole(r);
const tok = params.get('invite_token') || params.get('invite') || params.get('token');
if (tok) {
inviteToken = tok;
document.getElementById('invite-details').open = true;
document.getElementById('invite-code').value = tok;
validateInvite(tok);
}
if (params.get('nome')) document.getElementById('firstname').value = params.get('nome');
if (params.get('cognome')) document.getElementById('lastname').value = params.get('cognome');
if (params.get('email')) document.getElementById('email').value = params.get('email');
})();
// --- Invite Code Validation ---
let inviteTimer = null;
function validateInvite(val) {
const statusEl = document.getElementById('invite-status');
clearTimeout(inviteTimer);
val = val.trim();
if (!val) {
inviteValid = false; inviteToken = null; statusEl.innerHTML = ''; statusEl.className = 'lookup-status'; return;
}
statusEl.className = 'lookup-status loading';
statusEl.textContent = 'Verifica codice...';
inviteTimer = setTimeout(async () => {
try {
const res = await fetch(api.baseUrl + '/auth/validate-invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite_token: val })
});
const data = await res.json();
if (data.success) {
inviteValid = true; inviteToken = val;
statusEl.className = 'lookup-status ok';
const label = data.data?.label ? ` — ${data.data.label}` : '';
statusEl.textContent = `Codice valido${label} — accesso B2B confermato`;
if (data.role && document.getElementById('card-' + data.role)) selectRole(data.role);
const r = data.data?.recipient;
if (r) {
if (r.first_name) document.getElementById('firstname').value = r.first_name;
if (r.last_name) document.getElementById('lastname').value = r.last_name;
if (r.email) document.getElementById('email').value = r.email;
if (r.vat) { document.getElementById('piva').value = r.vat; lookupPiva(r.vat); }
inviteRecipient = r;
isReducedReg = !!(r.first_name && r.last_name && r.email);
}
if (isReducedReg) applyReducedRegMode();
} else {
inviteValid = false; inviteToken = null;
statusEl.className = 'lookup-status err';
statusEl.textContent = 'Codice non valido';
}
} catch {
inviteValid = false;
statusEl.className = 'lookup-status err';
statusEl.textContent = 'Impossibile verificare';
}
}, 600);
}
// --- P.IVA Lookup ---
function validaFormatoPiva(v) {
if (!/^\d{11}$/.test(v)) return false;
let s = 0;
for (let i = 0; i < 10; i++) {
let n = parseInt(v[i]);
if (i % 2 === 1) { n *= 2; if (n > 9) n -= 9; }
s += n;
}
return (10 - (s % 10)) % 10 === parseInt(v[10]);
}
function lookupPiva(val) {
clearTimeout(pivaLookupTimer);
val = val.replace(/\s/g, '');
const statusEl = document.getElementById('piva-status');
if (val.length < 11) { pivaData = null; statusEl.innerHTML = ''; statusEl.className = 'lookup-status'; return; }
if (val.length === 11 && !validaFormatoPiva(val)) {
pivaData = null;
statusEl.className = 'lookup-status err';
statusEl.textContent = 'P.IVA non valida (cifra di controllo errata)';
return;
}
statusEl.className = 'lookup-status ok';
statusEl.textContent = 'P.IVA valida — ricerca dati azienda...';
pivaLookupTimer = setTimeout(async () => {
try {
const res = await fetch(api.baseUrl + '/onboarding/lookup-piva', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vat_number: val })
});
const data = await res.json();
if (data.success && data.data && data.data.company_name) {
pivaData = data.data;
statusEl.className = 'lookup-status ok';
statusEl.textContent = data.data.company_name;
} else {
pivaData = null;
statusEl.className = 'lookup-status ok';
statusEl.textContent = 'P.IVA valida — inserisci i dati aziendali nel passo successivo';
}
} catch {
pivaData = null;
statusEl.className = 'lookup-status ok';
statusEl.textContent = 'P.IVA valida — inserisci i dati aziendali nel passo successivo';
}
}, 800);
}
// --- Registrazione Ridotta ---
function applyReducedRegMode() {
const name = ((inviteRecipient.first_name || '') + ' ' + (inviteRecipient.last_name || '')).trim();
document.getElementById('invite-welcome-name').textContent = 'Ciao ' + name + '!';
document.getElementById('invite-welcome-plan').textContent = 'Accesso B2B — account pre-configurato dall\'invito';
document.getElementById('invite-welcome').style.display = 'block';
['firstname', 'lastname', 'email'].forEach(id => {
const el = document.getElementById(id);
el.readOnly = true;
});
if (inviteRecipient.vat) document.getElementById('piva-group').style.display = 'none';
if (!selectedRole) selectRole('org_admin');
goToStep1();
}
// --- Step Navigation ---
function setStep(n) {
[0, 1, 2].forEach(i => {
document.getElementById('step-' + i).style.display = (i === n) ? 'block' : 'none';
const si = document.getElementById('si-' + i);
si.className = 'step-item' + (i < n ? ' done' : i === n ? ' active' : '');
if (i < n) si.querySelector('.step-circle').innerHTML = '<svg class="icon icon-xs icon-white" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>';
else si.querySelector('.step-circle').textContent = i + 1;
});
document.getElementById('conn-0').className = 'step-connector' + (n > 0 ? ' done' : '');
document.getElementById('conn-1').className = 'step-connector' + (n > 1 ? ' done' : '');
document.getElementById('step-indicator').style.display = (n === 2) ? 'none' : 'flex';
}
function goToStep1() {
if (!selectedRole) return;
document.getElementById('register-error').classList.add('d-none');
const labels = {
compliance_manager: 'Dati account — CISO / Compliance Manager',
org_admin: 'Dati account — Legale Rappresentante',
consultant: 'Dati account — Consulente Cybersecurity',
auditor: 'Dati account — Auditor / Revisore',
board_member: 'Dati account — Board Member / DPO'
};
document.getElementById('auth-subtitle').textContent = labels[selectedRole] || 'Dati account';
setStep(1);
document.getElementById('firstname').focus();
}
function goToStep0() {
document.getElementById('register-error').classList.add('d-none');
document.getElementById('auth-subtitle').textContent = 'Crea il tuo account';
setStep(0);
}
function goToDashboard() { window.location.href = successRedirect; }
function togglePw(id, btn) {
const inp = document.getElementById(id);
inp.type = (inp.type === 'password') ? 'text' : 'password';
const use = btn.querySelector('use');
use.setAttribute('href', 'vendor/bootstrap-italia/dist/svg/sprites.svg#' + (inp.type === 'text' ? 'it-password-invisible' : 'it-password-visible'));
}
// --- Password strength ---
document.getElementById('password').addEventListener('input', function() {
updateStrengthUI(calcPasswordStrength(this.value), this.value);
});
function calcPasswordStrength(pw) {
let s = 0;
if (pw.length >= 8) s++;
if (pw.length >= 12) s++;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++;
if (/\d/.test(pw)) s++;
if (/[^a-zA-Z0-9]/.test(pw)) s++;
return Math.min(4, Math.max(1, s <= 1 ? 1 : s === 2 ? 2 : s === 3 ? 3 : 4));
}
function updateStrengthUI(level, pw) {
const labels = { 1: 'Debole', 2: 'Sufficiente', 3: 'Buona', 4: 'Forte' };
const colors = { 1: '#d9364f', 2: '#e58a00', 3: '#06c', 4: '#008758' };
const bar = document.getElementById('ps-bar');
if (pw.length === 0) { bar.style.width = '0%'; document.getElementById('ps-text').textContent = ''; return; }
bar.style.width = (level * 25) + '%';
bar.style.backgroundColor = colors[level];
document.getElementById('ps-text').textContent = 'Sicurezza: ' + labels[level];
}
// --- Form Submit ---
document.getElementById('register-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('register-error');
errorEl.classList.add('d-none');
const firstname = document.getElementById('firstname').value.trim();
const lastname = document.getElementById('lastname').value.trim();
const email = document.getElementById('email').value.trim();
const piva = document.getElementById('piva').value.trim();
const password = document.getElementById('password').value;
const passwordConfirm = document.getElementById('password-confirm').value;
const fullname = firstname + ' ' + lastname;
function showErr(msg) { errorEl.textContent = msg; errorEl.classList.remove('d-none'); }
if (!firstname || !lastname || !email || !password) { showErr('Nome, cognome, email e password sono obbligatori.'); return; }
if (selectedRole !== 'consultant' && !piva) { showErr('Inserisci la Partita IVA della tua azienda.'); return; }
if (password.length < 8) { showErr('La password deve avere almeno 8 caratteri.'); return; }
if (password !== passwordConfirm) { showErr('Le password non coincidono.'); return; }
const btn = document.getElementById('register-btn');
btn.disabled = true;
btn.textContent = 'Registrazione in corso...';
try {
let result;
if (inviteToken) {
result = await fetch(api.baseUrl + '/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email, password, full_name: fullname,
role: selectedRole,
invite_token: inviteToken,
vat_number: piva || undefined
})
}).then(r => r.json());
} else {
result = await api.register(email, password, fullname, selectedRole);
}
if (result.success) {
if (inviteToken) {
successRedirect = 'dashboard-bi.html';
if (result.data?.access_token) {
api.setTokens(result.data.access_token, result.data.refresh_token);
localStorage.setItem('nis2_user_role', result.data.user?.role || selectedRole || 'org_admin');
}
try {
const companyName = pivaData?.company_name || inviteRecipient?.company || 'Azienda';
const vatNum = (piva || inviteRecipient?.vat || '').replace(/[^0-9]/g, '');
if (vatNum.length === 11) {
btn.textContent = 'Configurazione in corso...';
const provRes = await fetch(api.baseUrl + '/services/provision', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_token: inviteToken,
company: {
ragione_sociale: companyName,
partita_iva: vatNum,
sector: pivaData?.sector || 'ict',
nis2_entity_type: 'important'
},
admin: { email, full_name: fullname },
caller: { system: 'self-register' }
})
});
const provData = await provRes.json();
if (provData.success && provData.data?.org_id) {
localStorage.setItem('nis2_org_id', String(provData.data.org_id));
}
}
} catch { /* provision fallita */ }
} else if (selectedRole === 'consultant') {
successRedirect = 'companies.html';
} else {
successRedirect = 'onboarding-bi.html';
}
const titles = {
consultant: 'Account Consulente creato!',
board_member: 'Account Board Member creato!',
};
const descs = {
consultant: 'Puoi ora gestire i tuoi clienti dalla sezione Aziende.',
org_admin: pivaData ? 'Abbiamo trovato ' + pivaData.company_name + '. Completa l\'onboarding per configurare la tua organizzazione.' : 'Completa l\'onboarding per configurare la tua organizzazione NIS2.',
};
document.getElementById('success-title').textContent = titles[selectedRole] || 'Account creato!';
document.getElementById('success-desc').textContent = descs[selectedRole] || 'Il tuo account NIS2 Agile è pronto. Stai per essere reindirizzato...';
document.getElementById('auth-subtitle').textContent = 'Registrazione completata';
setStep(2);
setTimeout(goToDashboard, 2500);
} else {
showErr(result.message || 'Errore durante la registrazione.');
}
} catch (err) {
showErr('Errore di connessione al server.');
} finally {
btn.disabled = false;
btn.textContent = 'Crea Account';
}
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-188
View File
@@ -1,188 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Imposta nuova password - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 — self-hostato da /vendor (MAI CDN). Variante "-bi" della reset-password.html. -->
<link rel="stylesheet" href="vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<style>
body { min-height: 100vh; display: flex; align-items: center; background: #f5f6f7; }
.auth-wrap { width: 100%; max-width: 460px; margin: 0 auto; padding: 24px; }
.auth-brand { text-align: center; margin-bottom: 24px; }
.auth-brand .brand-name { font-weight: 700; font-size: 1.5rem; color: #06c; }
.auth-brand .brand-name span { color: #17324d; }
.auth-brand .brand-sub { color: #5a6772; font-size: .9rem; margin-top: 4px; }
.pw-wrap { position: relative; }
.pw-wrap .form-control { padding-right: 44px; }
.pw-toggle { position: absolute; right: 8px; top: 8px; background: none; border: none; color: #5a6772; cursor: pointer; padding: 6px; }
.ico { width: 1em; height: 1em; fill: currentColor; vertical-align: -.125em; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<main class="auth-wrap">
<div class="auth-brand">
<div class="brand-name">NIS2 <span>Agile</span></div>
<div class="brand-sub">Imposta una nuova password</div>
</div>
<div class="card card-bg has-bkg-grey shadow">
<div class="card-body">
<h1 class="h4 mb-3">Nuova password</h1>
<div class="alert alert-danger d-none" id="err" role="alert"></div>
<div class="alert alert-success d-none" id="ok" role="status"></div>
<form id="reset-form" novalidate>
<div class="form-group">
<label for="pw" class="active">Nuova password</label>
<div class="pw-wrap">
<input type="password" id="pw" class="form-control"
placeholder="Almeno 8 caratteri" autocomplete="new-password" required
aria-describedby="strength-text">
<button type="button" class="pw-toggle" tabindex="-1" onclick="togglePw('pw',this)" aria-label="Mostra o nascondi la password">
<svg class="icon icon-sm" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-password-visible"></use></svg>
</button>
</div>
<div class="progress mt-2" style="height:4px;">
<div class="progress-bar" id="strength-bar" role="progressbar" style="width:0%;" aria-hidden="true"></div>
</div>
<div class="form-text" id="strength-text" aria-live="polite"></div>
</div>
<div class="form-group">
<label for="pw2" class="active">Conferma password</label>
<div class="pw-wrap">
<input type="password" id="pw2" class="form-control"
placeholder="Ripeti la password" autocomplete="new-password" required>
<button type="button" class="pw-toggle" tabindex="-1" onclick="togglePw('pw2',this)" aria-label="Mostra o nascondi la password">
<svg class="icon icon-sm" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-password-visible"></use></svg>
</button>
</div>
</div>
<p class="text-muted small mb-3">Minimo 8 caratteri, con almeno una maiuscola, una minuscola e un numero.</p>
<button type="submit" class="btn btn-primary w-100" id="submit-btn">
Imposta password
</button>
</form>
</div>
</div>
<p class="text-center mt-3 mb-0">
<a href="login-bi.html">
<svg class="ico me-1" aria-hidden="true"><use href="vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-left"></use></svg>
Torna al login
</a>
</p>
</main>
<script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }
</script>
<!-- Stessa logica della reset-password.html: ZERO modifiche backend -->
<script>
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token') || '';
const err = document.getElementById('err');
const ok = document.getElementById('ok');
const form = document.getElementById('reset-form');
const btn = document.getElementById('submit-btn');
const pwInput = document.getElementById('pw');
const pwBar = document.getElementById('strength-bar');
const pwText = document.getElementById('strength-text');
if (!token) {
err.textContent = 'Token mancante. Richiedi un nuovo link di reset.';
err.classList.remove('d-none');
form.style.display = 'none';
}
function togglePw(id, button) {
const i = document.getElementById(id);
i.type = (i.type === 'password') ? 'text' : 'password';
const use = button.querySelector('use');
use.setAttribute('href', 'vendor/bootstrap-italia/dist/svg/sprites.svg#' + (i.type === 'text' ? 'it-password-invisible' : 'it-password-visible'));
}
function computeStrength(pw) {
let s = 0;
if (pw.length >= 8) s++;
if (pw.length >= 12) s++;
if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) s++;
if (/\d/.test(pw) && /[^A-Za-z0-9]/.test(pw)) s++;
return s;
}
pwInput.addEventListener('input', function() {
const s = computeStrength(pwInput.value);
const colors = ['#d4d8db', '#d9364f', '#e58a00', '#06c', '#008758'];
const labels = ['', 'Debole', 'Media', 'Buona', 'Ottima'];
pwBar.style.width = (pwInput.value.length > 0 ? (s * 25) : 0) + '%';
pwBar.style.backgroundColor = colors[s];
pwText.textContent = pwInput.value.length > 0 ? 'Sicurezza: ' + labels[s] : '';
});
form.addEventListener('submit', async function(e) {
e.preventDefault();
err.classList.add('d-none');
ok.classList.add('d-none');
const pw = document.getElementById('pw').value;
const pw2 = document.getElementById('pw2').value;
if (pw !== pw2) {
err.textContent = 'Le due password non coincidono.';
err.classList.remove('d-none');
return;
}
if (pw.length < 8) {
err.textContent = 'La password deve essere di almeno 8 caratteri.';
err.classList.remove('d-none');
return;
}
btn.disabled = true;
btn.textContent = 'Salvataggio...';
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token, new_password: pw })
});
const data = await res.json();
if (data.success) {
ok.textContent = data.message + ' Sarai reindirizzato al login.';
ok.classList.remove('d-none');
form.style.display = 'none';
setTimeout(function() { window.location.href = 'login-bi.html'; }, 2500);
} else {
err.textContent = data.message || 'Errore. Token non valido o scaduto.';
err.classList.remove('d-none');
}
} catch (e) {
err.textContent = 'Errore di connessione al server.';
err.classList.remove('d-none');
} finally {
btn.disabled = false;
btn.textContent = 'Imposta password';
}
});
</script>
</body>
</html>
-1386
View File
File diff suppressed because it is too large Load Diff
-247
View File
@@ -1,247 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Standard Continuita di Servizio (BI) — NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Titillium Web','Segoe UI',-apple-system,sans-serif;font-size:14.5px;line-height:1.7;color:#1A1A2E;background:#F5F7FA}
.icon-inline{vertical-align:-0.15em;margin-right:6px}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.page{max-width:960px;margin:0 auto;background:#fff;box-shadow:0 4px 40px rgba(0,0,0,.08)}
.doc-header{background:linear-gradient(135deg,#0D1B2A 0%,#06c 100%);color:#fff;padding:40px 48px}
.header-top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:22px}
.brand{display:flex;align-items:center;gap:10px}
.brand-icon{width:44px;height:44px;border-radius:10px;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.85)}
.brand-icon .icon{fill:currentColor}
.brand-name{font-size:1rem;font-weight:800}
.brand-sub{font-size:.68rem;opacity:.6}
.doc-meta{text-align:right;font-size:.75rem;opacity:.6}
.doc-meta strong{display:block;color:rgba(255,255,255,.9);opacity:1;font-size:.85rem}
.doc-title{font-size:1.65rem;font-weight:800;letter-spacing:-.02em;line-height:1.2;margin-bottom:6px}
.doc-title span{color:rgba(255,255,255,.85)}
.doc-sub{font-size:.84rem;opacity:.7}
.tag-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:18px}
.tag{display:inline-flex;align-items:center;gap:5px;padding:4px 12px;border-radius:20px;font-size:.7rem;font-weight:600;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);color:#fff}
.tag .icon{fill:currentColor}
.content{padding:36px 48px}
h2{font-size:1.25rem;font-weight:800;color:#06c;margin:36px 0 14px;padding-bottom:6px;border-bottom:2px solid #eee;display:flex;align-items:center;gap:8px}
h2:first-child{margin-top:0}
h2 .icon{fill:currentColor;flex:none}
h3{font-size:1rem;font-weight:700;color:#06c;margin:22px 0 8px}
p{margin:8px 0}ul,ol{margin:8px 0 8px 20px}li{margin:4px 0}
.info-box{background:#E8F5E9;border-left:4px solid #2E7D32;padding:14px 18px;border-radius:0 8px 8px 0;margin:16px 0;font-size:.88rem;display:flex;gap:8px;align-items:flex-start}
.info-box .icon{fill:#2E7D32;flex:none;margin-top:2px}
.warn-box{background:#FFF3E0;border-left:4px solid #F57F17;padding:14px 18px;border-radius:0 8px 8px 0;margin:16px 0;font-size:.88rem;display:flex;gap:8px;align-items:flex-start}
.warn-box .icon{fill:#F57F17;flex:none;margin-top:2px}
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:.82rem}
th{background:#f5f5f5;padding:10px 12px;text-align:left;font-weight:700;border-bottom:2px solid #ddd;font-size:.75rem;text-transform:uppercase;letter-spacing:.5px;color:#333}
td{padding:9px 12px;border-bottom:1px solid #F0F2F5;vertical-align:top}
tr:hover td{background:#FAFBFC}
code{background:#f5f5f5;padding:2px 6px;border-radius:4px;font-size:.8rem;font-family:'Roboto Mono','Courier New',monospace;color:#333}
pre{background:#1A1A2E;color:#E0E4E8;padding:16px 20px;border-radius:8px;overflow-x:auto;font-size:.78rem;line-height:1.5;margin:12px 0}
.layer{display:grid;grid-template-columns:60px 1fr;gap:0;margin:16px 0}
.layer-num{background:#06c;color:#fff;font-weight:800;font-size:1.1rem;display:flex;align-items:center;justify-content:center;border-radius:8px 0 0 8px}
.layer-body{background:#f9f9f9;padding:16px 20px;border-radius:0 8px 8px 0;border:1px solid #eee;border-left:none}
.layer-body h4{margin:0 0 6px;color:#06c;font-size:.95rem;display:flex;align-items:center;gap:6px}
.layer-body h4 .icon{fill:currentColor;flex:none}
.badge-ok{display:inline-block;background:#2E7D32;color:#fff;padding:2px 8px;border-radius:10px;font-size:.68rem;font-weight:700}
.badge-warn{display:inline-block;background:#F57F17;color:#fff;padding:2px 8px;border-radius:10px;font-size:.68rem;font-weight:700}
.badge-crit{display:inline-block;background:#C62828;color:#fff;padding:2px 8px;border-radius:10px;font-size:.68rem;font-weight:700}
.doc-footer{background:#f5f5f5;padding:24px 48px;border-top:1px solid #ddd;font-size:.78rem;color:#5F6B7A;display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px}
a{color:#06c}
:focus-visible{outline:3px solid #06c;outline-offset:2px}
@media print{body{background:#fff}.page{box-shadow:none}}
@media(max-width:700px){.content,.doc-header,.doc-footer{padding:20px}.layer{grid-template-columns:1fr}.layer-num{border-radius:8px 8px 0 0;padding:8px}.layer-body{border-radius:0 0 8px 8px;border-left:1px solid #eee}}
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="page">
<header class="doc-header">
<div class="header-top">
<div class="brand">
<div class="brand-icon"><svg class="icon icon-white" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-locked"></use></svg></div>
<div><div class="brand-name">NIS2 Agile</div><div class="brand-sub">Cybersecurity Compliance NIS2</div></div>
</div>
<div class="doc-meta"><strong>Standard Operativo</strong>Continuita di Servizio</div>
</div>
<h1 class="doc-title">Strategia di <span>Continuita di Servizio</span></h1>
<div class="doc-sub">Architettura di resilienza, monitoraggio, auto-recovery e protezione dei dati</div>
<div class="tag-row">
<span class="tag"><svg class="icon icon-xs icon-white" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-calendar"></use></svg> Aprile 2026</span>
<span class="tag"><svg class="icon icon-xs icon-white" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-software"></use></svg> PHP+MySQL+Qdrant</span>
<span class="tag"><svg class="icon icon-xs icon-white" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg> Hetzner CPX31</span>
<span class="tag"><svg class="icon icon-xs icon-white" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-chart-line"></use></svg> 99.9% SLA Target</span>
</div>
</header>
<main class="content">
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg> 1. Architettura di Servizio</h2>
<p><strong>NIS2 Agile</strong> e composto dai seguenti servizi gestiti sulla piattaforma Hetzner condivisa con gli altri prodotti della suite Agile Technology.</p>
<table><caption class="sr-only">Servizi, stack tecnologico e healthcheck</caption><thead><tr><th scope="col">Servizio</th><th scope="col">Stack</th><th scope="col">Healthcheck</th></tr></thead><tbody><tr><td><code>nis2-app</code></td><td>PHP+MySQL+Qdrant</td><td><span class="badge-ok">/health</span></td></tr><tr><td><code>MySQL/PostgreSQL</code></td><td>Database condiviso</td><td><span class="badge-ok">ping</span></td></tr><tr><td><code>Qdrant</code></td><td>Vector DB (embeddings)</td><td><span class="badge-ok">HTTP 200</span></td></tr><tr><td><code>AgileHub (NEXUS)</code></td><td>Ticket + AI + Notifiche</td><td><span class="badge-ok">/health</span></td></tr></tbody></table>
<div class="info-box">
<svg class="icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg>
<div><strong>Deploy a caldo</strong>: le modifiche al codice sorgente vengono applicate via bind mount Docker o PM2 reload. Nessun rebuild necessario per aggiornamenti ordinari.</div>
</div>
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-files"></use></svg> 2. Strategia di Resilienza a 7 Livelli</h2>
<div class="layer"><div class="layer-num">L1</div><div class="layer-body">
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-refresh"></use></svg> Auto-Restart Container / PM2</h4>
<p>Ogni servizio ha <code>restart: unless-stopped</code> (Docker) o <code>pm2 --watch</code> (Node.js). Se un processo crasha, viene riavviato automaticamente entro 10-30 secondi.</p>
<p><strong>Tempo di recovery</strong>: &lt; 30 secondi</p>
</div></div>
<div class="layer"><div class="layer-num">L2</div><div class="layer-body">
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-chart-line"></use></svg> Healthcheck Nativo</h4>
<p>Ogni servizio espone un endpoint <code>/health</code> o <code>/api/health</code> verificato ogni 30 secondi. Se il check fallisce 3 volte consecutive, il container/processo viene ricreato automaticamente.</p>
<p><strong>Rileva</strong>: crash applicativo, memoria esaurita, deadlock, loop infinito.</p>
</div></div>
<div class="layer"><div class="layer-num">L3</div><div class="layer-body">
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-inbox"></use></svg> Database Retry con Reconnect</h4>
<p>Tutti i servizi implementano retry automatico su errori transitori del database (MySQL/PostgreSQL):</p>
<ul>
<li><strong>2006</strong> — Server has gone away (reconnect)</li>
<li><strong>2013</strong> — Lost connection during query (retry)</li>
<li><strong>1213</strong> — Deadlock found (retry con backoff)</li>
</ul>
<p>Strategia: 2 retry con backoff esponenziale + reconnect automatico.</p>
</div></div>
<div class="layer"><div class="layer-num">L4</div><div class="layer-body">
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-exchange-circle"></use></svg> Resilienza API Esterne</h4>
<p>Le chiamate ad API esterne (Claude AI, ElevenLabs TTS, Voyage embeddings) hanno protezione a 3 livelli:</p>
<ol>
<li><strong>Retry con backoff</strong>: 3 tentativi su errori transitori (429, 500, 502, 503)</li>
<li><strong>Degradazione graceful</strong>: se l'AI non risponde, il prodotto funziona con i dati locali</li>
<li><strong>Frontend fallback</strong>: messaggio "AI temporaneamente non disponibile" con suggerimenti alternativi</li>
</ol>
</div></div>
<div class="layer"><div class="layer-num">L5</div><div class="layer-body">
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-horn"></use></svg> Error Alerting in Tempo Reale</h4>
<p>Ogni errore critico genera una notifica AgileHub (campanella) per l'admin e, se configurato, un'email di alert. L'admin vede l'errore entro 30 secondi via polling badge.</p>
</div></div>
<div class="layer"><div class="layer-num">L6</div><div class="layer-body">
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-password-visible"></use></svg> Monitoraggio Esterno AgileHub</h4>
<p>Il cron AgileHub (<code>ticket-agent-cron.sh</code>) verifica lo stato di tutti i container ogni 2 minuti. Se un prodotto non e raggiungibile, viene segnalato nel log e inviata notifica.</p>
<p>Complementare ai healthcheck interni: rileva problemi di rete, proxy Apache, DNS.</p>
</div></div>
<div class="layer"><div class="layer-num">L7</div><div class="layer-body">
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-tool"></use></svg> Manutenzione Preventiva</h4>
<p>Script automatici prevengono il degrado:</p>
<table>
<caption class="sr-only">Operazioni di manutenzione preventiva</caption>
<thead><tr><th scope="col">Operazione</th><th scope="col">Frequenza</th><th scope="col">Funzione</th></tr></thead>
<tbody>
<tr><td>Disk monitor</td><td>Ogni 6 ore</td><td>Alert email se disco &gt; 85%</td></tr>
<tr><td>Cleanup</td><td>Settimanale</td><td>Elimina file temporanei, Docker prune</td></tr>
<tr><td>SSL renewal</td><td>Settimanale</td><td>Rinnovo automatico Let's Encrypt</td></tr>
<tr><td>Claude auth</td><td>Orario</td><td>Refresh token OAuth Claude Code</td></tr>
</tbody>
</table>
</div></div>
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg> 3. Protezione da Abuso</h2>
<h3>Rate Limiting</h3>
<table>
<caption class="sr-only">Limiti di rate limiting per endpoint</caption>
<thead><tr><th scope="col">Endpoint</th><th scope="col">Limite</th><th scope="col">Finestra</th><th scope="col">Tipo</th></tr></thead>
<tbody>
<tr><td>Login</td><td>5 tentativi</td><td>15 min</td><td>Per IP + lockout</td></tr>
<tr><td>API generiche</td><td>100 req</td><td>1 ora</td><td>Per chiave/utente</td></tr>
<tr><td>Password reset</td><td>3 req</td><td>1 ora</td><td>Per email</td></tr>
<tr><td>Webhook esterni</td><td>50 req</td><td>1 min</td><td>Per IP</td></tr>
</tbody>
</table>
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-tool"></use></svg> 4. Modalita Manutenzione</h2>
<p>Quando il sistema AgileHub applica un fix approvato, il <strong>maintenance mode</strong> si attiva automaticamente:</p>
<ol>
<li>Il cron agent crea il flag <code>/tmp/maintenance-NIS2.flag</code></li>
<li>Apache rileva il flag e mostra la pagina di manutenzione a tutti gli utenti</li>
<li>Il fix viene applicato (Claude nel container DevEnv)</li>
<li>Il flag viene rimosso — il prodotto torna online</li>
<li>La pagina di manutenzione si auto-aggiorna e reindirizza gli utenti</li>
</ol>
<div class="warn-box">
<svg class="icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning-circle"></use></svg>
<div><strong>Nessun intervento manuale richiesto</strong>: il maintenance mode e completamente automatico. L'utente vede un messaggio professionale e il sistema torna online da solo.</div>
</div>
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning"></use></svg> 5. Matrice Rischi e Mitigazione</h2>
<table>
<caption class="sr-only">Matrice rischi, probabilita, impatto, mitigazione e recovery</caption>
<thead><tr><th scope="col">Scenario</th><th scope="col">Probabilita</th><th scope="col">Impatto</th><th scope="col">Mitigazione</th><th scope="col">Recovery</th></tr></thead>
<tbody>
<tr><td>Crash singolo servizio</td><td><span class="badge-warn">Media</span></td><td><span class="badge-warn">Medio</span></td><td>Auto-restart Docker/PM2</td><td>&lt; 30s</td></tr>
<tr><td>Database restart</td><td><span class="badge-ok">Bassa</span></td><td><span class="badge-crit">Alto</span></td><td>DB retry + reconnect</td><td>&lt; 5s</td></tr>
<tr><td>AI non disponibile</td><td><span class="badge-warn">Media</span></td><td><span class="badge-ok">Basso</span></td><td>3 retry + graceful</td><td>Funzioni base intatte</td></tr>
<tr><td>Disco pieno</td><td><span class="badge-ok">Bassa</span></td><td><span class="badge-crit">Alto</span></td><td>Monitor 6h + cleanup</td><td>Alert prima del 85%</td></tr>
<tr><td>SSL scaduto</td><td><span class="badge-ok">Bassa</span></td><td><span class="badge-crit">Alto</span></td><td>Auto-renewal certbot</td><td>Rinnovo 30gg prima</td></tr>
<tr><td>Attacco brute force</td><td><span class="badge-warn">Media</span></td><td><span class="badge-warn">Medio</span></td><td>Rate limit + lockout</td><td>Blocco 15 min</td></tr>
<tr><td>Errore deployment</td><td><span class="badge-warn">Media</span></td><td><span class="badge-crit">Alto</span></td><td>Maintenance mode + git revert</td><td>&lt; 2 min rollback</td></tr>
</tbody>
</table>
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-card"></use></svg> 6. Service Level Agreement</h2>
<table>
<caption class="sr-only">Metriche di Service Level Agreement</caption>
<thead><tr><th scope="col">Metrica</th><th scope="col">Target</th><th scope="col">Misurazione</th></tr></thead>
<tbody>
<tr><td>Uptime servizio</td><td><strong>99.9%</strong> (8.7h downtime/anno)</td><td>Health monitor + log</td></tr>
<tr><td>Recovery da crash</td><td>&lt; 30 secondi</td><td>Docker restart + healthcheck</td></tr>
<tr><td>Recovery da aggiornamento</td><td>&lt; 5 minuti</td><td>Maintenance mode + deploy</td></tr>
<tr><td>Rilevamento downtime</td><td>&lt; 5 minuti</td><td>Cron monitor + email alert</td></tr>
<tr><td>Tempo risposta API (p95)</td><td>&lt; 500ms</td><td>Health endpoint response time</td></tr>
</tbody>
</table>
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check-circle"></use></svg> 7. Checklist Operativa</h2>
<h3>Verifica automatica (quotidiana)</h3>
<ul>
<li>Tutti i container/processi in stato healthy/online</li>
<li>Campanella AgileHub — nessuna notifica di errore sistema</li>
<li>Email — nessun alert da health monitor</li>
</ul>
<h3>Prima di un aggiornamento manuale</h3>
<ol>
<li>Verificare che non ci siano utenti attivi (o attivare maintenance mode)</li>
<li>Eseguire l'aggiornamento</li>
<li>Verificare tutti i servizi: <code>docker compose ps</code> o <code>pm2 list</code></li>
<li>Controllare i log per errori</li>
</ol>
</main>
<footer class="doc-footer">
<div>&copy; 2026 NIS2 Agile &mdash; <a href="https://nis2.agile.software">nis2.agile.software</a></div>
<div>Standard Continuita di Servizio v1.0 &bull; Aprile 2026 &bull; Agile Technology SRL</div>
</footer>
</div>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-756
View File
@@ -1,756 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Formazione (BI) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (tabelle/modali/form) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260613b">
<style>
/* ── Tab Navigation ─────────────────────────────────────── */
.tab-nav {
display: flex;
gap: 0;
border-bottom: 2px solid var(--gray-200);
margin-bottom: 24px;
}
.tab-nav button {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
color: var(--gray-500);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.tab-nav button:hover {
color: var(--gray-700);
background: var(--gray-50);
}
.tab-nav button.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ── Course Cards Grid ──────────────────────────────────── */
.courses-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.course-card {
background: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
padding: 20px;
transition: box-shadow var(--transition);
position: relative;
display: flex;
flex-direction: column;
}
.course-card:hover {
box-shadow: var(--card-shadow-hover);
}
.course-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.course-card-title {
font-size: 1rem;
font-weight: 600;
color: var(--gray-800);
margin: 0;
}
.course-card-desc {
font-size: 0.85rem;
color: var(--gray-500);
margin-bottom: 16px;
flex-grow: 1;
line-height: 1.5;
}
.course-card-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 0.8rem;
}
.course-meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--gray-500);
background: var(--gray-100);
padding: 4px 10px;
border-radius: 20px;
}
.course-meta-item svg { width: 14px; height: 14px; }
.badge-mandatory {
background: var(--danger-bg);
color: var(--danger);
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ── Assignments Table ──────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
background: var(--card-bg);
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: var(--card-shadow);
}
.data-table thead {
background: var(--gray-50);
}
.data-table th {
text-align: left;
padding: 12px 16px;
font-size: 0.8rem;
font-weight: 600;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--gray-200);
}
.data-table td {
padding: 12px 16px;
font-size: 0.875rem;
color: var(--gray-700);
border-bottom: 1px solid var(--gray-100);
}
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td { background: var(--gray-50); }
/* ── Status Badges ──────────────────────────────────────── */
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 500;
gap: 4px;
}
.status-badge.assigned { background: var(--gray-100); color: var(--gray-600); }
.status-badge.in_progress { background: var(--info-bg); color: var(--info); }
.status-badge.completed { background: var(--secondary-bg); color: var(--secondary); }
.status-badge.overdue { background: var(--danger-bg); color: var(--danger); }
/* ── Compliance Percentage Bar ──────────────────────────── */
.compliance-bar-container {
display: flex;
align-items: center;
gap: 10px;
}
.compliance-bar {
flex-grow: 1;
height: 8px;
background: var(--gray-200);
border-radius: 4px;
overflow: hidden;
}
.compliance-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.compliance-pct {
font-size: 0.85rem;
font-weight: 600;
min-width: 45px;
text-align: right;
}
.compliance-pct.high { color: var(--secondary); }
.compliance-pct.medium { color: var(--warning); }
.compliance-pct.low { color: var(--danger); }
/* ── Spinner/Loading ────────────────────────────────────── */
.loading-state {
text-align: center;
padding: 48px 20px;
color: var(--gray-400);
}
.loading-state .spinner {
width: 32px;
height: 32px;
border: 3px solid var(--gray-200);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Empty State ────────────────────────────────────────── */
.empty-state-box {
text-align: center;
padding: 48px 20px;
color: var(--gray-400);
}
.empty-state-box svg {
width: 48px;
height: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.empty-state-box h4 {
color: var(--gray-600);
margin-bottom: 4px;
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.courses-grid { grid-template-columns: 1fr; }
.tab-nav button { padding: 10px 14px; font-size: 0.82rem; }
.data-table { font-size: 0.8rem; }
}
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="training.title">Formazione e Awareness</h1>
<div class="content-header-actions">
<span class="text-muted" style="font-size:0.8rem; margin-right:8px;">Art. 20 NIS2</span>
<button class="btn btn-primary" onclick="showCreateCourseModal()">+ Nuovo Corso</button>
</div>
</header>
<div class="content-body">
<!-- Tab Navigation -->
<div class="tab-nav">
<button class="active" onclick="switchTab('courses', this)">Corsi</button>
<button onclick="switchTab('assignments', this)">Le Mie Assegnazioni</button>
<button onclick="switchTab('compliance', this)">Compliance Formativa</button>
</div>
<!-- Tab: Corsi -->
<div class="tab-panel active" id="tab-courses">
<div class="courses-grid" id="courses-grid">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento corsi...</p>
</div>
</div>
</div>
<!-- Tab: Le Mie Assegnazioni -->
<div class="tab-panel" id="tab-assignments">
<div id="assignments-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento assegnazioni...</p>
</div>
</div>
</div>
<!-- Tab: Compliance Formativa -->
<div class="tab-panel" id="tab-compliance">
<div style="display:flex; justify-content:flex-end; margin-bottom:16px;">
<button class="btn btn-primary" onclick="showAssignCourseModal()">Assegna Corso</button>
</div>
<div id="compliance-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento dati compliance...</p>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260613"></script>
<script src="/js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260615"></script>
<script src="/js/i18n.js?v=20260615"></script>
<script src="/js/help.js?v=20260615"></script>
<script>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
HelpSystem.init();
// ── Labels ──────────────────────────────────────────────
const roleLabels = {
all: 'Tutti',
board_member: 'Organi di Gestione',
compliance_manager: 'Compliance Manager',
employee: 'Dipendente',
technical: 'Tecnico'
};
const statusLabels = {
assigned: 'Assegnato',
in_progress: 'In Corso',
completed: 'Completato',
overdue: 'Scaduto'
};
// ── State ───────────────────────────────────────────────
let allCourses = [];
let allAssignments = [];
let allCompliance = [];
// ── Tab Switch ──────────────────────────────────────────
function switchTab(tabId, btn) {
document.querySelectorAll('.tab-nav button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + tabId).classList.add('active');
if (tabId === 'courses' && allCourses.length === 0) loadCourses();
if (tabId === 'assignments') loadAssignments();
if (tabId === 'compliance') loadCompliance();
}
// ── Load Courses ────────────────────────────────────────
async function loadCourses() {
const container = document.getElementById('courses-grid');
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento corsi...</p></div>';
try {
const result = await api.listCourses();
if (result.success && result.data) {
allCourses = result.data;
renderCourses(allCourses);
} else {
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4><p>' + escapeHtml(result.message || '') + '</p></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4><p>Impossibile caricare i corsi.</p></div>';
}
}
function renderCourses(courses) {
const container = document.getElementById('courses-grid');
if (!courses || courses.length === 0) {
container.innerHTML = `
<div class="empty-state-box" style="grid-column: 1/-1;">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838L7.667 9.088l1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3z"/></svg>
<h4>Nessun corso disponibile</h4>
<p>Crea il primo corso di formazione per iniziare.</p>
</div>`;
return;
}
let html = '';
courses.forEach(course => {
const mandatory = course.is_mandatory == 1;
html += `
<div class="course-card">
<div class="course-card-header">
<h4 class="course-card-title">${escapeHtml(course.title)}</h4>
${mandatory ? '<span class="badge-mandatory">Obbligatorio</span>' : ''}
</div>
<p class="course-card-desc">${escapeHtml(course.description || 'Nessuna descrizione disponibile.')}</p>
<div class="course-card-meta">
${course.duration_minutes ? `
<span class="course-meta-item">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
${course.duration_minutes} min
</span>
` : ''}
<span class="course-meta-item">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/></svg>
${roleLabels[course.target_role] || course.target_role || 'Tutti'}
</span>
${course.nis2_article ? `
<span class="course-meta-item">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>
Art. ${escapeHtml(course.nis2_article)}
</span>
` : ''}
${course.passing_score ? `
<span class="course-meta-item">
Soglia: ${course.passing_score}%
</span>
` : ''}
</div>
</div>`;
});
container.innerHTML = html;
}
// ── Create Course Modal ─────────────────────────────────
function showCreateCourseModal() {
const roleOptions = Object.entries(roleLabels).map(([val, label]) =>
`<option value="${val}">${label}</option>`
).join('');
showModal('Nuovo Corso di Formazione', `
<div class="form-group">
<label class="form-label">Titolo *</label>
<input type="text" class="form-input" id="course-title" placeholder="Es: Fondamenti Cybersecurity NIS2" required>
</div>
<div class="form-group">
<label class="form-label">Descrizione</label>
<textarea class="form-input" id="course-description" rows="3" placeholder="Descrizione del corso..."></textarea>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<div class="form-group">
<label class="form-label">Ruolo Destinatario</label>
<select class="form-select" id="course-target-role">${roleOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Durata (minuti)</label>
<input type="number" class="form-input" id="course-duration" min="1" placeholder="60">
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<div class="form-group">
<label class="form-label">Articolo NIS2</label>
<input type="text" class="form-input" id="course-nis2-article" placeholder="Es: Art. 20">
</div>
<div class="form-group">
<label class="form-label">Punteggio Minimo (%)</label>
<input type="number" class="form-input" id="course-passing-score" min="0" max="100" value="70">
</div>
</div>
<div class="form-group" style="margin-top:8px;">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer;">
<input type="checkbox" id="course-mandatory">
<span class="form-label" style="margin:0;">Corso obbligatorio</span>
</label>
</div>
`, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="createCourse()">Crea Corso</button>
`
});
}
async function createCourse() {
const title = document.getElementById('course-title').value.trim();
if (!title) {
showNotification('Il titolo e\' obbligatorio.', 'warning');
return;
}
const data = {
title: title,
description: document.getElementById('course-description').value.trim(),
target_role: document.getElementById('course-target-role').value,
is_mandatory: document.getElementById('course-mandatory').checked ? 1 : 0,
duration_minutes: parseInt(document.getElementById('course-duration').value) || null,
nis2_article: document.getElementById('course-nis2-article').value.trim() || null,
passing_score: parseInt(document.getElementById('course-passing-score').value) || 70,
};
closeModal();
showNotification('Creazione corso in corso...', 'info');
try {
const result = await api.post('/training/courses', data);
if (result.success) {
showNotification('Corso creato con successo!', 'success');
loadCourses();
} else {
showNotification(result.message || 'Errore nella creazione del corso.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Load Assignments ────────────────────────────────────
async function loadAssignments() {
const container = document.getElementById('assignments-container');
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento assegnazioni...</p></div>';
try {
const result = await api.getMyTraining();
if (result.success && result.data) {
allAssignments = result.data;
renderAssignments(allAssignments);
} else {
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4></div>';
}
}
function renderAssignments(assignments) {
const container = document.getElementById('assignments-container');
if (!assignments || assignments.length === 0) {
container.innerHTML = `
<div class="empty-state-box">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5z" clip-rule="evenodd"/></svg>
<h4>Nessuna assegnazione</h4>
<p>Non hai ancora corsi assegnati.</p>
</div>`;
return;
}
let html = `
<table class="data-table">
<thead>
<tr>
<th>Corso</th>
<th>Stato</th>
<th>Scadenza</th>
<th>Punteggio Quiz</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>`;
assignments.forEach(a => {
const isOverdue = a.due_date && new Date(a.due_date) < new Date() && a.status !== 'completed';
const displayStatus = isOverdue ? 'overdue' : a.status;
let actionBtn = '';
if (displayStatus === 'assigned' || displayStatus === 'overdue') {
actionBtn = `<button class="btn btn-primary btn-sm" onclick="updateAssignment(${a.id}, 'in_progress')">Inizia</button>`;
} else if (displayStatus === 'in_progress') {
actionBtn = `<button class="btn btn-primary btn-sm" onclick="updateAssignment(${a.id}, 'completed')">Completa</button>`;
} else {
actionBtn = `<span style="color:var(--secondary); font-weight:500; font-size:0.85rem;">Completato</span>`;
}
html += `
<tr>
<td>
<div style="font-weight:500;">${escapeHtml(a.title || '')}</div>
${a.is_mandatory == 1 ? '<span style="font-size:0.75rem; color:var(--danger);">Obbligatorio</span>' : ''}
</td>
<td><span class="status-badge ${displayStatus}">${statusLabels[displayStatus] || displayStatus}</span></td>
<td>${a.due_date ? formatDate(a.due_date) : '-'}</td>
<td>${a.quiz_score != null ? a.quiz_score + '%' : '-'}</td>
<td>${actionBtn}</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
async function updateAssignment(id, status) {
try {
const result = await api.put(`/training/assignments/${id}`, { status: status });
if (result.success) {
const msg = status === 'in_progress' ? 'Corso iniziato!' : 'Corso completato!';
showNotification(msg, 'success');
loadAssignments();
} else {
showNotification(result.message || 'Errore nell\'aggiornamento.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Load Compliance Status ──────────────────────────────
async function loadCompliance() {
const container = document.getElementById('compliance-container');
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento dati compliance...</p></div>';
try {
const result = await api.getTrainingCompliance();
if (result.success && result.data) {
allCompliance = result.data;
renderCompliance(allCompliance);
} else {
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4></div>';
}
}
function renderCompliance(members) {
const container = document.getElementById('compliance-container');
if (!members || members.length === 0) {
container.innerHTML = `
<div class="empty-state-box">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/></svg>
<h4>Nessun membro trovato</h4>
<p>Nessun membro dell\'organizzazione trovato.</p>
</div>`;
return;
}
let html = `
<table class="data-table">
<thead>
<tr>
<th>Nome</th>
<th>Email</th>
<th>Ruolo</th>
<th>Corsi Completati</th>
<th>% Compliance Obbligatoria</th>
</tr>
</thead>
<tbody>`;
members.forEach(m => {
const assignments = m.assignments || [];
const completed = assignments.filter(a => a.status === 'completed').length;
const total = assignments.length;
const pct = m.mandatory_compliance != null ? m.mandatory_compliance : 100;
const pctClass = pct >= 80 ? 'high' : pct >= 50 ? 'medium' : 'low';
const barColor = pct >= 80 ? 'var(--secondary)' : pct >= 50 ? 'var(--warning)' : 'var(--danger)';
html += `
<tr>
<td style="font-weight:500;">${escapeHtml(m.full_name || '-')}</td>
<td>${escapeHtml(m.email || '-')}</td>
<td>${escapeHtml(m.role || '-')}</td>
<td>${completed} / ${total}</td>
<td>
<div class="compliance-bar-container">
<div class="compliance-bar">
<div class="compliance-bar-fill" style="width:${pct}%; background:${barColor};"></div>
</div>
<span class="compliance-pct ${pctClass}">${pct}%</span>
</div>
</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
// ── Assign Course Modal ─────────────────────────────────
async function showAssignCourseModal() {
// Load courses if not already loaded
if (allCourses.length === 0) {
try {
const result = await api.listCourses();
if (result.success && result.data) {
allCourses = result.data;
}
} catch (e) { /* ignore */ }
}
if (allCourses.length === 0) {
showNotification('Nessun corso disponibile. Crea prima un corso.', 'warning');
return;
}
const courseOptions = allCourses.map(c =>
`<option value="${c.id}">${escapeHtml(c.title)}</option>`
).join('');
// Load members for selection
let membersHtml = '<p class="text-muted">Caricamento membri...</p>';
if (allCompliance.length > 0) {
membersHtml = allCompliance.map(m => `
<label style="display:flex; align-items:center; gap:8px; padding:6px 0; cursor:pointer;">
<input type="checkbox" class="assign-user-cb" value="${m.id}">
<span>${escapeHtml(m.full_name || m.email)}</span>
<span style="color:var(--gray-400); font-size:0.8rem;">(${escapeHtml(m.role || '')})</span>
</label>
`).join('');
}
showModal('Assegna Corso', `
<div class="form-group">
<label class="form-label">Corso</label>
<select class="form-select" id="assign-course-id">${courseOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Scadenza</label>
<input type="date" class="form-input" id="assign-due-date">
</div>
<div class="form-group">
<label class="form-label">Seleziona Utenti</label>
<div style="max-height:200px; overflow-y:auto; border:1px solid var(--gray-200); border-radius:var(--border-radius-sm); padding:8px 12px;">
${membersHtml}
</div>
</div>
`, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="doAssignCourse()">Assegna</button>
`
});
}
async function doAssignCourse() {
const courseId = document.getElementById('assign-course-id').value;
const dueDate = document.getElementById('assign-due-date').value || null;
const checkboxes = document.querySelectorAll('.assign-user-cb:checked');
const userIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
if (userIds.length === 0) {
showNotification('Seleziona almeno un utente.', 'warning');
return;
}
closeModal();
showNotification('Assegnazione in corso...', 'info');
try {
const result = await api.post('/training/assign', {
course_id: parseInt(courseId),
user_ids: userIds,
due_date: dueDate
});
if (result.success) {
showNotification(result.message || 'Corso assegnato con successo!', 'success');
loadCompliance();
} else {
showNotification(result.message || 'Errore nell\'assegnazione.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Initial Load ────────────────────────────────────────
loadCourses();
</script>
</body>
</html>
-472
View File
@@ -1,472 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Segnalazioni Sicurezza (BI) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di
style.css cosi' il design system esistente resta autorevole per i widget
renderizzati via JS (tabelle/modali/timeline) -> parita' funzionale assoluta.
BI veste la chrome (sidebar + font istituzionale). -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260613b">
<style>
.priority-badge { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; }
.priority-critical { background: var(--danger-bg); color: var(--danger); }
.priority-high { background: #fff7ed; color: #c2410c; }
.priority-medium { background: var(--warning-bg); color: #a16207; }
.priority-low { background: var(--gray-100); color: var(--gray-600); }
.status-badge { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; }
.status-received { background: rgba(6,182,212,0.1); color: #0891b2; }
.status-under_review { background: var(--warning-bg); color: #a16207; }
.status-investigating { background: #f3e8ff; color: #7c3aed; }
.status-resolved, .status-closed { background: var(--success-bg); color: var(--success); }
.status-rejected { background: var(--danger-bg); color: var(--danger); }
.anonymous-shield { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; background: rgba(16,185,129,0.1); border-radius: 12px; color: var(--success); font-size: 0.75rem; font-weight: 600; }
.art32-banner { background: linear-gradient(135deg, rgba(6,182,212,0.1), rgba(16,185,129,0.1)); border: 1px solid rgba(6,182,212,0.2); border-radius: var(--border-radius-lg); padding: 20px 24px; margin-bottom: 28px; }
.art32-banner h3 { font-size: 0.9375rem; font-weight: 700; color: var(--gray-800); margin-bottom: 6px; }
.art32-banner p { font-size: 0.8125rem; color: var(--gray-600); }
.track-form { background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: var(--border-radius); padding: 20px; margin-bottom: 24px; }
/* Icone sprite BI dimensionate inline per i bottoni azione */
.icon-inline { width: 16px; height: 16px; fill: currentColor; vertical-align: text-bottom; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<div class="page-header">
<div class="page-header-content">
<h1 class="page-title">Segnalazioni Sicurezza</h1>
<p class="page-subtitle">Canale interno Art.32 NIS2 — Segnala violazioni e anomalie di sicurezza</p>
</div>
<div class="page-header-actions">
<button class="btn btn-secondary" onclick="switchView('track')">
<svg class="icon-inline" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-search"></use></svg>
Traccia Segnalazione Anonima
</button>
<button class="btn btn-primary" onclick="switchView('submit')">
<svg class="icon-inline" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-plus"></use></svg>
Nuova Segnalazione
</button>
</div>
</div>
<!-- Art.32 Banner -->
<div class="art32-banner">
<h3>Art. 32 D.Lgs. 138/2024 — Canale Segnalazioni Interno</h3>
<p>Le entità NIS2 devono predisporre canali interni per la segnalazione di violazioni alla sicurezza informatica. Le segnalazioni anonime sono protette e il segnalante non può essere identificato. Ogni segnalazione viene gestita con priorità e tracciabilità.</p>
</div>
<!-- Stats Cards -->
<div class="stats-grid mb-24" id="stats-grid">
<div class="stat-card"><div class="spinner"></div></div>
</div>
<!-- Views -->
<div id="view-list">
<!-- Filters -->
<div class="card mb-16">
<div class="card-body" style="padding:16px 20px;">
<div style="display:flex; gap:12px; flex-wrap:wrap; align-items:center;">
<label for="filter-status" class="sr-only">Filtra per stato</label>
<select id="filter-status" class="form-control" style="width:auto;" onchange="loadReports()" aria-label="Filtra per stato">
<option value="">Tutti gli stati</option>
<option value="received">Ricevute</option>
<option value="under_review">In revisione</option>
<option value="investigating">In indagine</option>
<option value="resolved">Risolte</option>
<option value="closed">Chiuse</option>
</select>
<label for="filter-priority" class="sr-only">Filtra per priorità</label>
<select id="filter-priority" class="form-control" style="width:auto;" onchange="loadReports()" aria-label="Filtra per priorità">
<option value="">Tutte le priorità</option>
<option value="critical">Critica</option>
<option value="high">Alta</option>
<option value="medium">Media</option>
<option value="low">Bassa</option>
</select>
<label for="filter-category" class="sr-only">Filtra per categoria</label>
<select id="filter-category" class="form-control" style="width:auto;" onchange="loadReports()" aria-label="Filtra per categoria">
<option value="">Tutte le categorie</option>
<option value="security_incident">Incidente sicurezza</option>
<option value="data_breach">Data breach</option>
<option value="unauthorized_access">Accesso non autorizzato</option>
<option value="policy_violation">Violazione policy</option>
<option value="supply_chain_risk">Rischio supply chain</option>
<option value="nis2_non_compliance">Non conformità NIS2</option>
<option value="other">Altro</option>
</select>
</div>
</div>
</div>
<div id="reports-container"><div class="spinner" style="margin:60px auto;"></div></div>
</div>
<!-- Submit Form -->
<div id="view-submit" style="display:none;">
<div class="card" style="max-width:720px;">
<div class="card-header">
<h3>Nuova Segnalazione</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Segnala anonimamente o con il tuo nome. L'anonimato è garantito dalla piattaforma.</p>
</div>
<div class="card-body">
<div style="margin-bottom:20px; padding:12px 16px; background:rgba(16,185,129,0.08); border:1px solid rgba(16,185,129,0.2); border-radius:var(--border-radius); display:flex; align-items:center; gap:10px;">
<svg class="icon-inline" style="width:18px; height:18px; color:var(--success); flex-shrink:0;" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg>
<label style="display:flex; align-items:center; gap:8px; font-size:0.875rem; cursor:pointer;">
<input type="checkbox" id="is-anonymous" checked style="accent-color:var(--success);">
<span><strong>Segnalazione anonima</strong> — La tua identità non verrà registrata</span>
</label>
</div>
<form id="submit-form" onsubmit="submitReport(event)">
<div class="form-group">
<label class="form-label" for="rep-category">Categoria *</label>
<select id="rep-category" class="form-control" required>
<option value="">Seleziona categoria...</option>
<option value="security_incident">Incidente di sicurezza</option>
<option value="data_breach">Data breach / Violazione dati</option>
<option value="unauthorized_access">Accesso non autorizzato</option>
<option value="policy_violation">Violazione policy interna</option>
<option value="supply_chain_risk">Rischio supply chain</option>
<option value="nis2_non_compliance">Non conformità NIS2</option>
<option value="corruption">Corruzione / Frode</option>
<option value="other">Altro</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="rep-title">Titolo *</label>
<input type="text" id="rep-title" class="form-control" placeholder="Breve descrizione dell'anomalia..." required>
</div>
<div class="form-group">
<label class="form-label" for="rep-description">Descrizione dettagliata *</label>
<textarea id="rep-description" class="form-control" rows="6" placeholder="Descrivi cosa hai osservato, quando è successo, chi è coinvolto (se noto), e qualsiasi altra informazione utile..." required></textarea>
</div>
<div class="form-group">
<label class="form-label" for="rep-priority">Priorità stimata</label>
<select id="rep-priority" class="form-control">
<option value="medium">Media</option>
<option value="high">Alta</option>
<option value="critical">Critica — Azione immediata richiesta</option>
<option value="low">Bassa</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="rep-article">Articolo NIS2 violato (opzionale)</label>
<input type="text" id="rep-article" class="form-control" placeholder="Es. Art.21, Art.23...">
</div>
<div class="form-group" id="contact-email-group">
<label class="form-label" for="rep-email">Email per follow-up (opzionale)</label>
<input type="email" id="rep-email" class="form-control" placeholder="Email solo per ricevere aggiornamenti">
<p style="font-size:0.75rem; color:var(--gray-500); margin-top:4px;">Lascia vuoto per mantenere l'anonimato completo. Oppure fornisci un'email temporanea.</p>
</div>
<div style="display:flex; gap:12px; margin-top:24px;">
<button type="submit" class="btn btn-primary">Invia Segnalazione</button>
<button type="button" class="btn btn-secondary" onclick="switchView('list')">Annulla</button>
</div>
</form>
</div>
</div>
</div>
<!-- Track Anonymous -->
<div id="view-track" style="display:none;">
<div class="card" style="max-width:600px;">
<div class="card-header">
<h3>Traccia Segnalazione Anonima</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Inserisci il token ricevuto al momento della segnalazione per verificarne lo stato.</p>
</div>
<div class="card-body">
<div class="form-group">
<label class="form-label" for="track-token">Token Segnalazione</label>
<input type="text" id="track-token" class="form-control" placeholder="Inserisci il token...">
</div>
<div style="display:flex; gap:12px;">
<button class="btn btn-primary" onclick="trackReport()">Verifica Stato</button>
<button class="btn btn-secondary" onclick="switchView('list')">Annulla</button>
</div>
<div id="track-result" style="margin-top:24px;"></div>
</div>
</div>
</div>
</main>
</div>
<!-- Report Detail Modal handled by common.js showModal -->
<script src="/js/api.js?v=20260613"></script>
<script src="/js/common.js?v=20260615"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260615"></script>
<script src="/js/i18n.js?v=20260615"></script>
<script src="/js/help.js?v=20260615"></script>
<script>
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
let currentView = 'list';
const categoryLabels = {
security_incident: 'Incidente sicurezza', data_breach: 'Data breach',
unauthorized_access: 'Accesso non autorizzato', policy_violation: 'Violazione policy',
supply_chain_risk: 'Rischio supply chain', nis2_non_compliance: 'Non conformità NIS2',
corruption: 'Corruzione/Frode', fraud: 'Frode', other: 'Altro'
};
const priorityLabels = { critical: 'Critica', high: 'Alta', medium: 'Media', low: 'Bassa' };
const statusLabels = {
received: 'Ricevuta', under_review: 'In revisione', investigating: 'In indagine',
resolved: 'Risolta', closed: 'Chiusa', rejected: 'Respinta'
};
function switchView(view) {
currentView = view;
['list','submit','track'].forEach(v => {
document.getElementById('view-' + v).style.display = v === view ? 'block' : 'none';
});
if (view === 'list') { loadStats(); loadReports(); }
}
async function loadStats() {
try {
const result = await api.request('GET', '/whistleblowing/stats');
if (result.success) renderStats(result.data.stats);
} catch (e) {}
}
function renderStats(s) {
const grid = document.getElementById('stats-grid');
grid.innerHTML = `
<div class="stat-card">
<div class="stat-label">Totale Segnalazioni</div>
<div class="stat-value">${s.total || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Da Gestire</div>
<div class="stat-value" style="color:var(--warning);">${(s.received || 0) + (s.under_review || 0)}</div>
</div>
<div class="stat-card">
<div class="stat-label">In Indagine</div>
<div class="stat-value" style="color:var(--primary);">${s.investigating || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Critiche/Alte</div>
<div class="stat-value" style="color:var(--danger);">${(s.critical || 0) + (s.high || 0)}</div>
</div>`;
}
async function loadReports() {
const container = document.getElementById('reports-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
try {
const params = new URLSearchParams();
const status = document.getElementById('filter-status').value;
const priority = document.getElementById('filter-priority').value;
const category = document.getElementById('filter-category').value;
if (status) params.append('status', status);
if (priority) params.append('priority', priority);
if (category) params.append('category', category);
const result = await api.request('GET', '/whistleblowing/list?' + params);
if (result.success) renderReports(result.data.reports || []);
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore caricamento segnalazioni</h4></div>';
}
}
function renderReports(reports) {
const container = document.getElementById('reports-container');
if (!reports.length) {
container.innerHTML = `
<div class="empty-state">
<svg class="icon-inline" style="width:40px; height:40px; color:var(--gray-300);" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning-circle"></use></svg>
<h4>Nessuna segnalazione trovata</h4>
<p>Il canale di segnalazione è attivo. Le segnalazioni appariranno qui.</p>
</div>`;
return;
}
let html = `<div class="table-container"><table>
<thead><tr><th scope="col">Codice</th><th scope="col">Categoria</th><th scope="col">Titolo</th><th scope="col">Priorità</th><th scope="col">Stato</th><th scope="col">Anonima</th><th scope="col">Assegnata</th><th scope="col">Data</th><th scope="col"></th></tr></thead><tbody>`;
reports.forEach(r => {
html += `<tr>
<td><code style="font-size:0.8rem;">${escapeHtml(r.report_code)}</code></td>
<td><span class="badge badge-neutral">${escapeHtml(categoryLabels[r.category] || r.category)}</span></td>
<td><strong style="font-size:0.875rem;">${escapeHtml(r.title)}</strong></td>
<td><span class="priority-badge priority-${r.priority}">${escapeHtml(priorityLabels[r.priority] || r.priority)}</span></td>
<td><span class="status-badge status-${r.status}">${escapeHtml(statusLabels[r.status] || r.status)}</span></td>
<td>${r.is_anonymous ? '<span class="anonymous-shield"><svg class="icon-inline" style="width:12px;height:12px;" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg>Anonima</span>' : '<span style="color:var(--gray-400); font-size:0.75rem;">Firmata</span>'}</td>
<td>${r.assigned_to_name ? escapeHtml(r.assigned_to_name) : '<span style="color:var(--gray-400);">—</span>'}</td>
<td style="font-size:0.8rem;">${formatDate(r.created_at)}</td>
<td><button class="btn btn-sm btn-secondary" onclick="viewReport(${r.id})">Gestisci</button></td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
async function viewReport(id) {
try {
const result = await api.request('GET', `/whistleblowing/${id}`);
if (!result.success) { showNotification('Errore caricamento.', 'error'); return; }
const r = result.data;
const timelineHtml = (r.timeline || []).map(t => `
<div style="padding:10px 0; border-bottom:1px solid var(--gray-100); display:flex; gap:12px;">
<div style="width:8px; height:8px; border-radius:50%; background:var(--primary); margin-top:6px; flex-shrink:0;"></div>
<div>
<p style="font-size:0.8125rem; color:var(--gray-700);">${escapeHtml(t.description)}</p>
<p style="font-size:0.75rem; color:var(--gray-400);">${formatDateTime(t.created_at)} ${t.created_by_name ? '— ' + escapeHtml(t.created_by_name) : ''}</p>
</div>
</div>`).join('');
showModal(`${escapeHtml(r.report_code)} — ${escapeHtml(r.title)}`, `
<div style="display:flex; gap:10px; flex-wrap:wrap; margin-bottom:16px;">
<span class="priority-badge priority-${r.priority}">${escapeHtml(priorityLabels[r.priority] || r.priority)}</span>
<span class="status-badge status-${r.status}">${escapeHtml(statusLabels[r.status] || r.status)}</span>
${r.is_anonymous ? '<span class="anonymous-shield"><svg class="icon-inline" style="width:12px;height:12px;" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg>Anonima</span>' : ''}
</div>
<div style="padding:12px 16px; background:var(--gray-50); border-radius:var(--border-radius); margin-bottom:16px; font-size:0.875rem; line-height:1.6;">
${escapeHtml(r.description)}
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:16px; font-size:0.8125rem;">
<div><strong>Categoria:</strong> ${escapeHtml(categoryLabels[r.category] || r.category)}</div>
<div><strong>Art. NIS2:</strong> ${r.nis2_article ? escapeHtml(r.nis2_article) : '—'}</div>
<div><strong>Assegnata a:</strong> ${r.assigned_to_name ? escapeHtml(r.assigned_to_name) : '—'}</div>
<div><strong>Data:</strong> ${formatDateTime(r.created_at)}</div>
</div>
<h4 style="font-size:0.8125rem; font-weight:700; margin-bottom:8px;">Timeline</h4>
<div>${timelineHtml || '<p style="color:var(--gray-400); font-size:0.8rem;">Nessun evento in timeline.</p>'}</div>
<div style="margin-top:20px; display:flex; flex-direction:column; gap:10px;">
<label for="modal-status" class="sr-only">Cambia stato</label>
<select id="modal-status" class="form-control">
<option value="">Cambia stato...</option>
${['under_review','investigating','resolved','closed','rejected'].map(s =>
`<option value="${s}" ${r.status === s ? 'selected' : ''}>${escapeHtml(statusLabels[s])}</option>`
).join('')}
</select>
<label for="modal-notes" class="sr-only">Note di risoluzione</label>
<textarea id="modal-notes" class="form-control" rows="3" placeholder="Note di risoluzione...">${escapeHtml(r.resolution_notes || '')}</textarea>
</div>
`, `<button class="btn btn-primary" onclick="updateReport(${id})">Salva</button>
<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>`);
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
async function updateReport(id) {
const status = document.getElementById('modal-status').value;
const notes = document.getElementById('modal-notes').value;
const body = {};
if (status) body.status = status;
if (notes) body.resolution_notes = notes;
if (!Object.keys(body).length) { closeModal(); return; }
try {
const result = await api.request('PUT', `/whistleblowing/${id}`, body);
if (result.success) {
closeModal();
showNotification('Segnalazione aggiornata.', 'success');
loadReports(); loadStats();
} else showNotification(result.message || 'Errore.', 'error');
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
async function submitReport(e) {
e.preventDefault();
const btn = e.target.querySelector('[type=submit]');
btn.disabled = true; btn.textContent = 'Invio...';
try {
const body = {
category: document.getElementById('rep-category').value,
title: document.getElementById('rep-title').value,
description: document.getElementById('rep-description').value,
priority: document.getElementById('rep-priority').value,
nis2_article: document.getElementById('rep-article').value,
is_anonymous: document.getElementById('is-anonymous').checked ? 1 : 0,
contact_email: document.getElementById('rep-email').value,
organization_id: localStorage.getItem('nis2_org_id'),
};
const result = await api.request('POST', '/whistleblowing/submit', body);
if (result.success) {
const token = result.data.anonymous_token;
showModal('Segnalazione Inviata', `
<div style="padding:16px; background:rgba(16,185,129,0.1); border-radius:var(--border-radius); margin-bottom:16px;">
<p style="font-weight:700; color:var(--success);">Segnalazione ricevuta con codice: <code>${escapeHtml(result.data.report_code)}</code></p>
</div>
${token ? `<p style="font-size:0.875rem; color:var(--gray-600); margin-bottom:8px;">Token per tracking anonimo (conservalo):</p>
<div style="padding:12px; background:var(--gray-100); border-radius:var(--border-radius); font-family:monospace; font-size:0.8rem; word-break:break-all; user-select:all;">${escapeHtml(token)}</div>` : ''}
`, `<button class="btn btn-primary" onclick="closeModal(); switchView('list');">OK</button>`);
e.target.reset();
} else {
showNotification(result.message || 'Errore.', 'error');
}
} catch (err) { showNotification('Errore di connessione.', 'error'); }
finally { btn.disabled = false; btn.textContent = 'Invia Segnalazione'; }
}
async function trackReport() {
const token = document.getElementById('track-token').value.trim();
if (!token) { showNotification('Inserisci il token.', 'error'); return; }
const container = document.getElementById('track-result');
container.innerHTML = '<div class="spinner"></div>';
try {
const result = await api.request('GET', '/whistleblowing/track-anonymous?token=' + encodeURIComponent(token));
if (result.success) {
const r = result.data;
const tl = (r.timeline || []).map(t => `
<div style="display:flex; gap:10px; padding:8px 0; border-bottom:1px solid var(--gray-100);">
<div style="width:6px; height:6px; border-radius:50%; background:var(--primary); margin-top:7px; flex-shrink:0;"></div>
<div><p style="font-size:0.8125rem;">${escapeHtml(t.description)}</p>
<p style="font-size:0.75rem; color:var(--gray-400);">${formatDateTime(t.created_at)}</p></div>
</div>`).join('');
container.innerHTML = `
<div style="padding:16px; border:1px solid var(--gray-200); border-radius:var(--border-radius);">
<div style="display:flex; gap:10px; margin-bottom:12px;">
<code style="font-size:0.875rem;">${escapeHtml(r.report_code)}</code>
<span class="status-badge status-${r.status}">${escapeHtml(statusLabels[r.status] || r.status)}</span>
</div>
<p style="font-size:0.8125rem; color:var(--gray-600); margin-bottom:12px;">Categoria: ${escapeHtml(categoryLabels[r.category] || r.category)}</p>
<div>${tl}</div>
</div>`;
} else {
container.innerHTML = '<div class="empty-state"><h4>Token non valido o segnalazione non trovata</h4></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
}
}
// Init
loadStats();
loadReports();
</script>
</body>
</html>