[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:
co-authored by
Claude Opus 4.8
parent
717f9ac396
commit
3c5184bbae
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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' : ''}>
|
|
||||||
« Precedente
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-sm" onclick="loadOrganizations(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
|
|
||||||
Successivo »
|
|
||||||
</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>
|
|
||||||
@@ -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' : ''}>
|
|
||||||
« Precedente
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-sm" onclick="loadUsers(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
|
|
||||||
Successivo »
|
|
||||||
</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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>≥${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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 & 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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"><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">© 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>
|
|
||||||
@@ -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 Autorità (ACN/CSIRT) 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>
|
|
||||||
@@ -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–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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;">
|
|
||||||
← 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... Puoi strutturare il documento con sezioni: 1. Scopo 2. Ambito 3. Responsabilita' 4. Procedure 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>
|
|
||||||
@@ -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
@@ -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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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>: < 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 > 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>< 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>< 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>< 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>< 30 secondi</td><td>Docker restart + healthcheck</td></tr>
|
|
||||||
<tr><td>Recovery da aggiornamento</td><td>< 5 minuti</td><td>Maintenance mode + deploy</td></tr>
|
|
||||||
<tr><td>Rilevamento downtime</td><td>< 5 minuti</td><td>Cron monitor + email alert</td></tr>
|
|
||||||
<tr><td>Tempo risposta API (p95)</td><td>< 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>© 2026 NIS2 Agile — <a href="https://nis2.agile.software">nis2.agile.software</a></div>
|
|
||||||
<div>Standard Continuita di Servizio v1.0 • Aprile 2026 • 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
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
Reference in New Issue
Block a user