Files
nis2-agile/public/risks.html
T
DevEnv nis2-agileandClaude Opus 4.8 cf224480cc [FEAT] Sessione + release: refresh token silenzioso + avviso "nuova versione"
Evita la "sessione scaduta" e i deploy che restano invisibili all'utente.
- api.js: refresh PROATTIVO dell'access token ~2 min prima della scadenza
  (scheduleTokenRefresh su exp del JWT, riprogrammato ad ogni setTokens) +
  ensureFreshToken() per rinnovo immediato al ritorno sulla scheda. Con refresh
  a 7gg l'utente resta loggato senza mai vedere il login.
- common.js: watch versione (poll version.json ogni 5 min + su visibilitychange)
  → banner "È disponibile una nuova versione — Aggiorna" (reload) quando cambia;
  nudge service worker (registration.update); su visibilitychange chiama anche
  api.ensureFreshToken(). Nessun hard-refresh manuale.
- Cache-buster api/common ?v=20260729b. version 1.25.12. Solo frontend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 16:07:57 +02:00

1503 lines
79 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Rischi · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (self-host only, nessun CDN esterno). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<!-- style.css resta caricato DOPO v3.css: veste i widget renderizzati via JS
(tabelle/matrice/modali/form/badge) che usano le classi del design system legacy.
È self-hostato (nessun CDN). v3.css governa chrome + shell (.wrap/.card-pad/.page-head). -->
<style>
/* ── View Toggle ─────────────────────────────────────── */
.view-toggle {
display: flex;
background: var(--gray-100);
border-radius: var(--border-radius);
padding: 3px;
gap: 2px;
}
.view-toggle button {
padding: 6px 16px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
color: var(--gray-500);
font-family: inherit;
transition: all var(--transition-fast);
}
.view-toggle button.active {
background: var(--card-bg);
color: var(--gray-800);
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* ── Filters Bar ─────────────────────────────────────── */
.filters-bar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 20px;
}
.filters-bar .form-select {
width: auto;
min-width: 160px;
padding: 8px 36px 8px 12px;
font-size: 0.8rem;
}
/* ── Risk Score Badge ────────────────────────────────── */
.risk-score {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
font-weight: 700;
font-size: 0.85rem;
color: #fff;
}
.risk-score.score-low { background: var(--secondary); }
.risk-score.score-medium { background: var(--warning); color: var(--gray-900); }
.risk-score.score-high { background: #f97316; }
.risk-score.score-critical { background: var(--danger); }
/* ── Risk Matrix ─────────────────────────────────────── */
.risk-matrix-container {
max-width: 680px;
margin: 0 auto;
}
.risk-matrix-wrapper {
display: flex;
gap: 8px;
}
.risk-matrix-y-label {
writing-mode: vertical-lr;
transform: rotate(180deg);
text-align: center;
font-weight: 700;
font-size: 0.8rem;
color: var(--gray-600);
display: flex;
align-items: center;
justify-content: center;
padding-right: 4px;
}
.risk-matrix-grid-wrapper {
flex: 1;
}
.risk-matrix {
display: grid;
grid-template-columns: 40px repeat(5, 1fr);
grid-template-rows: repeat(5, 1fr) 40px;
gap: 3px;
width: 100%;
aspect-ratio: 1.15;
}
.matrix-y-header {
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
color: var(--gray-600);
}
.matrix-x-header {
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
color: var(--gray-600);
}
.matrix-corner {
/* empty corner cell */
}
.matrix-cell {
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: center;
cursor: default;
position: relative;
min-height: 64px;
transition: transform var(--transition-fast);
}
.matrix-cell:hover {
transform: scale(1.04);
z-index: 2;
}
.matrix-cell.level-1 { background: #dcfce7; }
.matrix-cell.level-2 { background: #bbf7d0; }
.matrix-cell.level-3 { background: #fef9c3; }
.matrix-cell.level-4 { background: #fde68a; }
.matrix-cell.level-5 { background: #fed7aa; }
.matrix-cell.level-6 { background: #fdba74; }
.matrix-cell.level-7 { background: #fb923c; }
.matrix-cell.level-8 { background: #fca5a5; }
.matrix-cell.level-9 { background: #f87171; }
.matrix-cell.level-10 { background: #ef4444; }
.matrix-dot {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255,255,255,0.9);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.75rem;
color: var(--gray-800);
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
}
.risk-matrix-x-label {
text-align: center;
font-weight: 700;
font-size: 0.8rem;
color: var(--gray-600);
margin-top: 8px;
}
.matrix-legend {
display: flex;
gap: 16px;
justify-content: center;
margin-top: 20px;
flex-wrap: wrap;
}
.matrix-legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: var(--gray-600);
}
.matrix-legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
}
/* ── Detail View ─────────────────────────────────────── */
.detail-back {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
font-weight: 600;
color: var(--gray-500);
cursor: pointer;
margin-bottom: 16px;
border: none;
background: none;
font-family: inherit;
padding: 0;
}
.detail-back:hover { color: var(--primary); }
.detail-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
}
.detail-field {
margin-bottom: 16px;
}
.detail-field-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
color: var(--gray-500);
margin-bottom: 4px;
}
.detail-field-value {
font-size: 0.9rem;
color: var(--gray-800);
}
.score-display {
display: flex;
gap: 24px;
padding: 16px;
background: var(--gray-50);
border-radius: var(--border-radius);
margin-bottom: 16px;
}
.score-item {
text-align: center;
}
.score-item-value {
font-size: 1.75rem;
font-weight: 800;
line-height: 1.2;
}
.score-item-label {
font-size: 0.7rem;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.treatment-item {
padding: 16px;
border: 1px solid var(--gray-200);
border-radius: var(--border-radius);
margin-bottom: 12px;
}
.treatment-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
/* ── Table clickable rows ───────────────────────────── */
.table-clickable tbody tr { cursor: pointer; }
/* ── Range value display ─────────────────────────────── */
.range-value-display {
text-align: center;
font-weight: 700;
font-size: 1.1rem;
color: var(--primary);
margin: 4px 0;
}
.range-label-text {
text-align: center;
font-size: 0.8rem;
color: var(--gray-600);
font-weight: 600;
}
.score-preview {
text-align: center;
padding: 12px;
background: var(--gray-50);
border-radius: var(--border-radius);
margin-top: 8px;
}
.score-preview-value {
font-size: 2rem;
font-weight: 800;
}
.score-preview-label {
font-size: 0.75rem;
color: var(--gray-500);
text-transform: uppercase;
}
@media (max-width: 768px) {
.detail-grid { grid-template-columns: 1fr; }
.filters-bar { flex-direction: column; align-items: stretch; }
.filters-bar .form-select { min-width: 100%; }
}
/* Requisiti del framework: riquadro a scorrimento verticale (ticket #501/1) */
#view-derived .table-container.scroll-box {
max-height: 60vh;
overflow-y: auto;
}
#view-derived .table-container.scroll-box thead th {
position: sticky;
top: 0;
background: var(--gray-50);
z-index: 2;
}
</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>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="wrap">
<nav class="crumb" aria-label="breadcrumb">
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="risks.title">Gestione Rischi</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="risks.title">Gestione Rischi</h1>
<p class="sub">Registro rischi e matrice 5×5</p>
</div>
<div class="content-header-actions" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<button class="btn-ghost" id="btn-ai-suggest" onclick="aiSuggest()">
<i data-lucide="sparkles" class="ic"></i>AI Suggerisci
</button>
<button class="btn-primary" id="btn-new-risk" onclick="showRiskModal()"><i data-lucide="plus" class="ic"></i>Nuovo Rischio</button>
</div>
</div>
<div class="content-body">
<!-- ── Nota introduttiva (framing normativo) ───────────── -->
<div class="risk-intro-note" style="display:flex;gap:12px;align-items:flex-start;padding:16px 18px;background:var(--gray-50);border:1px solid var(--gray-200);border-left:4px solid var(--primary);border-radius:var(--border-radius);margin-bottom:24px;">
<i data-lucide="info" class="ic" style="flex-shrink:0;color:var(--primary);margin-top:2px;"></i>
<p style="font-size:0.85rem;color:var(--gray-700);line-height:1.55;margin:0;">
In questa pagina i rischi corrispondono ai requisiti del framework di sicurezza applicabili alla tua organizzazione e alla loro valutazione. I requisiti derivano dalle misure di sicurezza di base della Determinazione ACN n. 164179 del 14/4/2025 (Allegato 1 per i soggetti importanti, Allegato 2 per gli essenziali). Ogni voce rappresenta il rischio derivante dal mancato o parziale soddisfacimento del requisito, non il requisito in sé. La valutazione e la matrice 5×5 sono uno strumento di rappresentazione: la normativa (art. 24 D.Lgs. 138/2024) richiede un'analisi dei rischi proporzionata e con approccio multirischio, senza imporre una metodologia specifica.
</p>
</div>
<!-- ── SEZIONE 1: Rischi derivati dai requisiti (PRIMARIA, read-only) ── -->
<div id="view-derived" style="margin-bottom:32px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:8px;margin-bottom:10px;">
<h2 style="font-size:1.05rem;margin:0;" data-i18n="risks.derived_heading">Rischi dai requisiti del framework di sicurezza</h2>
<span class="text-muted" style="font-size:0.78rem;" id="derived-source-note"></span>
</div>
<p class="text-muted" style="font-size:0.78rem;margin:0 0 12px;">
<span data-i18n="risks.derived_code_legend">RSK-‹misura›Com.‹n› è una codifica interna di NIS2 Agile; ‹misura› (es. GV.OC-04) è il codice della misura del Framework Nazionale richiamato dalla Determinazione ACN 164179/2025. Elenco in sola lettura.</span>
</p>
<div class="card">
<div class="table-container scroll-box">
<table>
<thead>
<tr>
<th scope="col" data-i18n="risks.col_code">Codice</th>
<th scope="col" data-i18n="risks.col_risk">Rischio</th>
<th scope="col" data-i18n="risks.col_measure">Misura</th>
<th scope="col" data-i18n="risks.col_conformity">Conformità</th>
<th scope="col" data-i18n="risks.col_probability">Probabilità</th>
<th scope="col" data-i18n="risks.col_impact">Impatto</th>
<th scope="col" data-i18n="risks.col_risk_level">Rischio (P×I)</th>
<th scope="col" data-i18n="risks.col_evaluation">Valutazione</th>
<th scope="col" id="derived-th-actions" data-i18n="risks.col_actions" style="display:none;">Azioni</th>
</tr>
</thead>
<tbody id="derived-table-body">
<tr>
<td colspan="5">
<div class="spinner" style="margin:40px auto;"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ── SEZIONE 2: Rischi aggiuntivi (multirischio / all-hazard) ── -->
<h2 style="font-size:1.05rem;margin:0 0 6px;" data-i18n="risks.custom_heading">Rischi aggiuntivi (approccio multirischio / all-hazard)</h2>
<p class="text-muted" style="font-size:0.82rem;margin:0 0 16px;line-height:1.5;" data-i18n="risks.custom_help">Oltre ai rischi derivati dai requisiti, i profili amministrativi possono aggiungere rischi non coperti da un singolo requisito (es. minacce fisiche, catena di fornitura, eventi naturali): copre l'approccio multirischio/all-hazard (art. 24 D.Lgs. 138/2024).</p>
<!-- Table View (rischi custom) -->
<div id="view-table">
<div class="filters-bar">
<select class="form-select" id="filter-category" onchange="loadRisks()" aria-label="Filtra per categoria">
<option value="">Tutte le categorie</option>
<option value="cyber">Cyber</option>
<option value="operational">Operativo</option>
<option value="compliance">Compliance</option>
<option value="supply_chain">Supply Chain</option>
<option value="physical">Fisico</option>
<option value="human">Umano</option>
</select>
<select class="form-select" id="filter-status" onchange="loadRisks()" aria-label="Filtra per stato">
<option value="">Tutti gli stati</option>
<option value="identified">Identificato</option>
<option value="analyzing">In Analisi</option>
<option value="treating">In Trattamento</option>
<option value="monitored">Monitorato</option>
<option value="closed">Chiuso</option>
</select>
</div>
<div class="card">
<div class="table-container">
<table class="table-clickable">
<thead>
<tr>
<th scope="col">Codice</th>
<th scope="col">Titolo</th>
<th scope="col">Categoria</th>
<th scope="col">Probabilita'</th>
<th scope="col">Impatto</th>
<th scope="col">Score</th>
<th scope="col">Stato</th>
<th scope="col">Azioni</th>
</tr>
</thead>
<tbody id="risks-table-body">
<tr>
<td colspan="8">
<div class="spinner" style="margin:40px auto;"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Matrix View (sempre visibile) -->
<div id="view-matrix" style="margin-top:16px;">
<div class="card">
<div class="card-header">
<h3>Matrice di Rischio 5x5</h3>
<span class="text-muted" style="font-size:0.8rem;" data-i18n="risks.matrix_subtitle">Rischi da requisiti (valutati) + aggiuntivi — esclusi i non applicabili</span>
</div>
<div class="card-body">
<div class="risk-matrix-container">
<div class="risk-matrix-wrapper">
<div class="risk-matrix-y-label">Probabilita'</div>
<div class="risk-matrix-grid-wrapper">
<div class="risk-matrix" id="risk-matrix-grid">
<div class="spinner" style="margin:60px auto; grid-column: span 6;"></div>
</div>
<div class="risk-matrix-x-label">Impatto</div>
</div>
</div>
<div class="matrix-legend">
<div class="matrix-legend-item">
<div class="matrix-legend-color" style="background:#bbf7d0;"></div>
Basso (1-4)
</div>
<div class="matrix-legend-item">
<div class="matrix-legend-color" style="background:#fde68a;"></div>
Medio (5-9)
</div>
<div class="matrix-legend-item">
<div class="matrix-legend-color" style="background:#fdba74;"></div>
Alto (10-14)
</div>
<div class="matrix-legend-item">
<div class="matrix-legend-color" style="background:#fb923c;"></div>
Elevato (15-19)
</div>
<div class="matrix-legend-item">
<div class="matrix-legend-color" style="background:#ef4444;"></div>
Critico (20-25)
</div>
</div>
</div>
<p class="text-muted" style="font-size:0.78rem;text-align:center;margin:16px auto 0;max-width:640px;line-height:1.5;" data-i18n="risks.matrix_caption">La matrice 5×5 è uno strumento di rappresentazione, non un obbligo normativo (art. 24 D.Lgs. 138/2024).</p>
</div>
</div>
</div>
<!-- Detail View -->
<div id="view-detail" class="hidden"></div>
</div>
</main>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → pagina -->
<script src="/js/api.js?v=20260729b"></script>
<script src="/js/common.js?v=20260729b"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260729"></script>
<script src="/js/help.js?v=20260729b"></script>
<script>
// ── Auth & Init ──────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
I18n.init();
HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
// ── Labels ───────────────────────────────────────────────────
const CATEGORY_LABELS = {
cyber: 'Cyber', operational: 'Operativo', compliance: 'Compliance',
supply_chain: 'Supply Chain', physical: 'Fisico', human: 'Umano'
};
const STATUS_LABELS = {
identified: 'Identificato', analyzing: 'In Analisi', treating: 'In Trattamento',
monitored: 'Monitorato', closed: 'Chiuso'
};
const TREATMENT_LABELS = {
mitigate: 'Mitigare', accept: 'Accettare', transfer: 'Trasferire', avoid: 'Evitare'
};
const TREATMENT_STATUS_LABELS = {
planned: 'Pianificato', in_progress: 'In Corso', completed: 'Completato', cancelled: 'Annullato'
};
// Scala Likelihood (probabilità annua)
const LIKELIHOOD_LABELS = ['', 'Molto Basso', 'Basso', 'Medio', 'Alto', 'Molto Alto'];
const LIKELIHOOD_DETAILS = [
'',
'1 - Remota: <1% probabilità annua (evento eccezionale)',
'2 - Improbabile: 1-10% (evento raro ma documentato nel settore)',
'3 - Possibile: 10-30% (può verificarsi nel corso dell\'attività)',
'4 - Probabile: 30-70% (ci si aspetta che avvenga almeno una volta l\'anno)',
'5 - Quasi Certa: >70% (si verificherà con elevata probabilità)',
];
// Scala Impact (danno economico / utenti impattati)
const IMPACT_DETAILS = [
'',
'1 - Minimo: <€10k danno, <100 utenti, servizio ripristinato in <1h',
'2 - Basso: €10k-50k, 100-500 utenti, ripristino <8h',
'3 - Significativo: €50k-500k, 500-5000 utenti, ripristino <24h',
'4 - Grave: €500k-10M, 5000-50000 utenti, ripristino <7 giorni',
'5 - Catastrofico: >€10M, >50000 utenti, servizi critici nazionali impattati',
];
// ── State ────────────────────────────────────────────────────
let currentView = 'table';
let risksData = [];
let derivedData = [];
let orgClass = null;
// Etichette impatto (per il modale Valuta).
const IMPACT_LABELS = ['', 'Trascurabile', 'Basso', 'Significativo', 'Grave', 'Catastrofico'];
// ── Gating ruolo: creazione/AI riservate ai profili amministrativi ──
const ADMIN_ROLES = ['super_admin', 'org_admin'];
(function gateAdminActions() {
const role = (api.getUserRole && api.getUserRole()) || '';
if (!ADMIN_ROLES.includes(role)) {
['btn-ai-suggest', 'btn-new-risk'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
}
})();
// ── Gating ruolo: valutazione rischi-da-requisito (opzione B) ──
// Chi può salvare la valutazione (stato + probabilità + impatto) di un requisito.
const EVAL_ROLES = ['super_admin', 'org_admin', 'compliance_manager'];
const canEvaluate = EVAL_ROLES.includes((api.getUserRole && api.getUserRole()) || '');
(function gateEvaluateColumn() {
if (canEvaluate) {
const th = document.getElementById('derived-th-actions');
if (th) th.style.display = '';
}
})();
// ── Load ─────────────────────────────────────────────────────
// Sequenza: prima i dati (derivati + custom) poi la matrice, che li
// aggrega client-side. loadMatrix() non fa più fetch (usa gli array in memoria).
(async function initRisks() {
await loadDerivedRisks();
await loadRisks();
loadMatrix();
})();
// ── Derived Risks (requisiti del framework) ──────────────────
// Badge conformità per lo stato di valutazione del requisito.
const DERIVED_STATUS_BADGE = {
da_valutare: { cls: 'badge-neutral', label: 'Da valutare' },
non_applicabile: { cls: 'badge-neutral', label: 'Non applicabile' },
non_conforme: { cls: 'badge-danger', label: 'Non conforme' },
parziale: { cls: 'badge-warning', label: 'Parziale' },
conforme: { cls: 'badge-success', label: 'Conforme' },
};
function isDerivedApplicable(r) {
// important → applicabili solo i requisiti Allegato 1; essential → Allegato 2;
// classe non determinata / adesione volontaria → tutti applicabili.
if (orgClass === 'important') return r.applies_important == 1;
if (orgClass === 'essential') return r.applies_essential == 1;
return true;
}
async function loadDerivedRisks() {
try {
const result = await api.derivedRisks();
if (result && result.success && result.data) {
orgClass = result.data.org_class || null;
derivedData = result.data.risks || [];
const src = result.data.source || '';
const note = document.getElementById('derived-source-note');
if (note && src) note.textContent = 'Fonte: ' + src;
renderDerivedTable(derivedData);
} else {
renderDerivedTable([]);
}
} catch (e) {
renderDerivedTable([]);
}
}
// Livello di rischio del punteggio derivato (P×I 1-25): opzione B.
// 1-4 basso/verde, 5-9 medio/giallo, 10-14 alto/arancio, 15-25 critico/rosso.
function getDerivedScoreClass(score) {
if (score >= 15) return 'score-critical';
if (score >= 10) return 'score-high';
if (score >= 5) return 'score-medium';
return 'score-low';
}
function renderDerivedTable(rows) {
const tbody = document.getElementById('derived-table-body');
const colspan = 8 + (canEvaluate ? 1 : 0);
// Ticket #501/2: mostra SOLO i requisiti applicabili alla classe del soggetto
// (importante → Allegato 1, essenziale → Allegato 2 della Det. ACN 164179/2025).
// Le righe non applicabili non vengono più elencate (prima erano solo attenuate).
rows = (rows || []).filter(isDerivedApplicable);
if (!rows || rows.length === 0) {
tbody.innerHTML = `
<tr><td colspan="${colspan}">
<div class="empty-state">
<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.944z" clip-rule="evenodd"/></svg>
<h4>Nessun requisito applicabile</h4>
<p>Non risultano requisiti del framework di sicurezza per questa organizzazione.</p>
</div>
</td></tr>`;
return;
}
let html = '';
rows.forEach(r => {
const applicable = isDerivedApplicable(r);
const st = DERIVED_STATUS_BADGE[r.stato] || DERIVED_STATUS_BADGE.da_valutare;
const measureCode = r.misura_code || '-';
const reqCode = r.requisito_code || '';
const reqDescr = r.requisito_descr || '';
const measureDescr = r.misura_descr || '';
const evalVal = (r.valutazione_rischio !== null && r.valutazione_rischio !== undefined && r.valutazione_rischio !== '')
? escapeHtml(String(r.valutazione_rischio)) : '—';
const rowStyle = applicable ? '' : 'style="opacity:0.5;"';
const riskTooltip = `Rischio derivante dal mancato o parziale soddisfacimento del requisito ${measureCode} ${reqCode}`.trim();
// Colonna "Rischio": etichetta + requisito come OGGETTO del rischio (mai il requisito nudo).
const conformityCell = applicable
? `<span class="badge ${st.cls}">${escapeHtml(st.label)}</span>`
: `<span class="badge badge-neutral">Non applicabile alla tua classe</span>`;
// Probabilità / Impatto / Rischio: solo per righe applicabili e non "non applicabili".
let probCell = '<td>—</td>', impCell = '<td>—</td>', scoreCell = '<td>—</td>';
if (applicable && r.stato !== 'non_applicabile') {
const l = (r.likelihood >= 1 && r.likelihood <= 5) ? parseInt(r.likelihood) : null;
const i = (r.impact >= 1 && r.impact <= 5) ? parseInt(r.impact) : null;
probCell = `<td>${l != null ? l + '/5' : '—'}</td>`;
impCell = `<td>${i != null ? i + '/5' : '—'}</td>`;
if (r.score != null && r.score !== '' && !isNaN(r.score)) {
const sc = parseInt(r.score);
scoreCell = `<td><span class="risk-score ${getDerivedScoreClass(sc)}" title="P${l != null ? l : '?'} × I${i != null ? i : '?'} = ${sc}">${sc}</span></td>`;
}
}
// Colonna Azioni (solo per i ruoli che possono valutare).
let actionsCell = '';
if (canEvaluate) {
actionsCell = applicable
? `<td><button class="btn btn-ghost btn-sm" onclick="openEvaluateModal(${r.requisito_id})" title="Valuta rischio"><i data-lucide="sliders-horizontal" class="ic"></i> Valuta</button></td>`
: '<td><span class="text-muted">—</span></td>';
}
html += `
<tr ${rowStyle}>
<td><code style="font-size:0.78rem;color:var(--primary);">${escapeHtml(r.risk_code || '-')}</code></td>
<td title="${escapeHtml(riskTooltip)}">
<div style="font-size:0.68rem;text-transform:uppercase;letter-spacing:0.04em;font-weight:600;color:var(--gray-500);margin-bottom:2px;">Rischio di non conformità</div>
<div style="font-size:0.86rem;color:var(--gray-800);">${escapeHtml(reqDescr)}</div>
</td>
<td>
<code style="font-size:0.75rem;color:var(--gray-600);">${escapeHtml(measureCode)}</code>
${measureDescr ? `<div style="font-size:0.78rem;color:var(--gray-500);margin-top:2px;">${escapeHtml(measureDescr)}</div>` : ''}
</td>
<td>${conformityCell}</td>
${probCell}
${impCell}
${scoreCell}
<td>${evalVal}</td>
${actionsCell}
</tr>`;
});
tbody.innerHTML = html;
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
}
// ── Modale "Valuta" (opzione B) — valutazione rischio-da-requisito ──
const DERIVED_STATUS_OPTIONS = [
['da_valutare', 'Da valutare'],
['non_applicabile', 'Non applicabile'],
['non_conforme', 'Non conforme'],
['parziale', 'Parziale'],
['conforme', 'Conforme'],
];
function scaleOptions(labels, cur) {
let o = '';
for (let v = 1; v <= 5; v++) {
o += `<option value="${v}" ${v == cur ? 'selected' : ''}>${v} - ${labels[v]}</option>`;
}
return o;
}
// Suggerimento (NON obbligo) di probabilità/impatto dallo stato di conformità:
// un requisito conforme ha rischio residuo basso, uno non conforme più alto.
// È solo un valore pre-compilato che l'utente può modificare (avallato nis2-expert).
function suggestScores(stato) {
switch (stato) {
case 'conforme': return { l: 1, i: 2 };
case 'parziale': return { l: 3, i: 3 };
case 'non_conforme': return { l: 4, i: 4 };
case 'non_applicabile': return { l: 1, i: 1 };
default: return { l: 3, i: 3 }; // da_valutare
}
}
function openEvaluateModal(requisitoId) {
if (!canEvaluate) return;
const r = (derivedData || []).find(x => String(x.requisito_id) === String(requisitoId));
if (!r) { showNotification('Rischio non trovato', 'error'); return; }
const curStato = r.stato || 'da_valutare';
const curNote = r.valutazione_rischio || '';
// Già valutato? uso i valori salvati; altrimenti pre-compilo un SUGGERIMENTO dallo stato.
const evaluated = (r.likelihood >= 1 && r.likelihood <= 5) && (r.impact >= 1 && r.impact <= 5);
const sug = suggestScores(curStato);
const curL = evaluated ? parseInt(r.likelihood) : sug.l;
const curI = evaluated ? parseInt(r.impact) : sug.i;
const statoOpts = DERIVED_STATUS_OPTIONS.map(([v, l]) =>
`<option value="${v}" ${v === curStato ? 'selected' : ''}>${escapeHtml(l)}</option>`).join('');
const content = `
<div style="padding:10px 12px;background:var(--gray-50);border:1px solid var(--gray-200);border-radius:var(--border-radius);margin-bottom:16px;">
<code style="font-size:0.78rem;color:var(--primary);">${escapeHtml(r.risk_code || '-')}</code>
<div style="font-size:0.68rem;text-transform:uppercase;letter-spacing:0.04em;font-weight:600;color:var(--gray-500);margin-top:4px;">Rischio di non conformità</div>
<div style="font-size:0.86rem;color:var(--gray-800);">${escapeHtml(r.requisito_descr || '')}</div>
</div>
<div class="form-group">
<label for="eval-stato" class="form-label">Conformità (stato del requisito)</label>
<select class="form-select" id="eval-stato">${statoOpts}</select>
</div>
<div class="form-row">
<div class="form-group">
<label for="eval-likelihood" class="form-label">Probabilità</label>
<select class="form-select" id="eval-likelihood">${scaleOptions(LIKELIHOOD_LABELS, curL)}</select>
<div style="font-size:0.72rem;color:var(--gray-500);margin-top:4px;">Probabilità che la minaccia si concretizzi sfruttando la carenza del requisito.</div>
</div>
<div class="form-group">
<label for="eval-impact" class="form-label">Impatto</label>
<select class="form-select" id="eval-impact">${scaleOptions(IMPACT_LABELS, curI)}</select>
<div style="font-size:0.72rem;color:var(--gray-500);margin-top:4px;">Impatto della non conformità o del conseguente incidente.</div>
</div>
</div>
${!evaluated ? `<p style="font-size:0.78rem;color:#0e7a4a;background:#eefbf3;border:1px solid #bfe8cf;border-radius:8px;padding:9px 11px;margin:0 0 14px;line-height:1.5;">💡 <b>Valori suggeriti</b> in base allo stato di conformità (conforme → basso, parziale → medio, non conforme → alto). È solo un <b>suggerimento</b>: modificali pure liberamente.</p>` : ''}
<div class="form-group">
<label for="eval-note" class="form-label">Valutazione del rischio <span style="color:var(--gray-400);font-size:0.75rem;font-weight:400;">(opzionale)</span></label>
<textarea class="form-textarea" id="eval-note" rows="3" placeholder="Note sulla valutazione del rischio">${escapeHtml(curNote)}</textarea>
</div>
<p class="text-muted" style="font-size:0.75rem;margin:0;line-height:1.5;">La matrice 5×5 è uno strumento di rappresentazione, non un obbligo normativo (art. 24 D.Lgs. 138/2024). I requisiti "non applicabili" sono esclusi dalla matrice.</p>
`;
showModal('Valuta rischio', content, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="saveEvaluation(${r.requisito_id})">Salva valutazione</button>
`
});
}
async function saveEvaluation(requisitoId) {
if (!canEvaluate) return;
const btn = document.querySelector('#modal-overlay .btn-primary');
const payload = {
requisito_id: parseInt(requisitoId),
stato: document.getElementById('eval-stato').value,
valutazione_rischio: document.getElementById('eval-note').value.trim(),
likelihood: parseInt(document.getElementById('eval-likelihood').value),
impact: parseInt(document.getElementById('eval-impact').value),
};
setButtonLoading(btn, true);
try {
// frameworkSetState è _acn: ritorna data e lancia su success=false.
// Inviamo SEMPRE stato+valutazione+probabilità+impatto insieme (UPSERT che sovrascrive).
await api.frameworkSetState(payload);
closeModal();
showNotification('Valutazione salvata', 'success');
await loadDerivedRisks();
loadMatrix();
} catch (e) {
setButtonLoading(btn, false);
showNotification(e.message || 'Errore nel salvataggio', 'error');
}
}
async function loadRisks() {
const params = {};
const cat = document.getElementById('filter-category').value;
const status = document.getElementById('filter-status').value;
if (cat) params.category = cat;
if (status) params.status = status;
try {
const result = await api.listRisks(params);
if (result.success) {
risksData = (result.data && result.data.items) || [];
renderRisksTable(risksData);
} else {
showNotification(result.message || 'Errore nel caricamento rischi', 'error');
}
} catch (e) {
showNotification('Errore di connessione', 'error');
}
}
// ── Table Render ─────────────────────────────────────────────
function renderRisksTable(risks) {
const tbody = document.getElementById('risks-table-body');
if (!risks || risks.length === 0) {
tbody.innerHTML = `
<tr><td colspan="8">
<div class="empty-state">
<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>
<h4>Nessun rischio registrato</h4>
<p>Clicca "Nuovo Rischio" per aggiungere il primo rischio, oppure usa "AI Suggerisci".</p>
</div>
</td></tr>`;
return;
}
let html = '';
risks.forEach(r => {
const score = r.inherent_risk_score || 0;
const scoreClass = getScoreClassForRisk(score);
const statusBadge = getStatusBadge(r.status);
const categoryLabel = CATEGORY_LABELS[r.category] || r.category;
html += `
<tr onclick="viewRiskDetail(${r.id})">
<td><code style="font-size:0.8rem; color:var(--primary);">${escapeHtml(r.risk_code || '-')}</code></td>
<td><strong>${escapeHtml(r.title)}</strong></td>
<td><span class="tag">${escapeHtml(categoryLabel)}</span></td>
<td>${r.likelihood || '-'}/5</td>
<td>${r.impact || '-'}/5</td>
<td><span class="risk-score ${scoreClass}">${score}</span></td>
<td>${statusBadge}</td>
<td>
<div class="btn-group" onclick="event.stopPropagation()">
<button class="btn btn-ghost btn-sm btn-icon" onclick="showRiskModal(${r.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>
<button class="btn btn-ghost btn-sm btn-icon" onclick="confirmDeleteRisk(${r.id}, '${escapeHtml(r.title)}')" title="Elimina">
<svg viewBox="0 0 20 20" fill="currentColor"><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>`;
});
tbody.innerHTML = html;
}
function getScoreClassForRisk(score) {
if (score >= 20) return 'score-critical';
if (score >= 15) return 'score-high';
if (score >= 10) return 'score-medium';
return 'score-low';
}
function getScoreColorForRisk(score) {
if (score >= 20) return 'var(--danger)';
if (score >= 15) return '#f97316';
if (score >= 10) return 'var(--warning)';
return 'var(--secondary)';
}
function getStatusBadge(status) {
const map = {
identified: 'badge-info', analyzing: 'badge-warning',
treating: 'badge-primary', monitored: 'badge-success', closed: 'badge-neutral'
};
const cls = map[status] || 'badge-neutral';
const label = STATUS_LABELS[status] || status;
return `<span class="badge ${cls}">${escapeHtml(label)}</span>`;
}
// ── Matrix ───────────────────────────────────────────────────
// Opzione B: la matrice aggrega, CLIENT-SIDE, TUTTI i rischi:
// - rischi DERIVATI dai requisiti con probabilità+impatto valorizzati,
// ESCLUSI i "non applicabili" (stato non_applicabile o non applicabili alla classe);
// - rischi AGGIUNTIVI custom (che portano già likelihood/impact).
// Legge dagli array in memoria (derivedData/risksData): niente fetch qui.
function loadMatrix() {
const points = [];
(derivedData || []).forEach(r => {
if (!isDerivedApplicable(r)) return; // esclude i non applicabili alla classe
if (r.stato === 'non_applicabile') return; // esclude i non applicabili
const l = parseInt(r.likelihood), i = parseInt(r.impact);
if (l >= 1 && l <= 5 && i >= 1 && i <= 5) {
points.push({ likelihood: l, impact: i, title: ((r.risk_code || '') + ' ' + (r.requisito_code || '')).trim() });
}
});
(risksData || []).forEach(r => {
const l = parseInt(r.likelihood), i = parseInt(r.impact);
if (l >= 1 && l <= 5 && i >= 1 && i <= 5) {
points.push({ likelihood: l, impact: i, title: r.title || (r.risk_code || 'Rischio') });
}
});
renderMatrix(points);
}
function renderMatrix(risks) {
const grid = document.getElementById('risk-matrix-grid');
// Build a map: `${likelihood}-${impact}` -> count
const cellMap = {};
risks.forEach(r => {
const key = `${r.likelihood || 0}-${r.impact || 0}`;
if (!cellMap[key]) cellMap[key] = [];
cellMap[key].push(r);
});
// Color level mapping for cell score (likelihood * impact)
function getCellLevel(score) {
if (score <= 1) return 'level-1';
if (score <= 2) return 'level-2';
if (score <= 4) return 'level-3';
if (score <= 6) return 'level-4';
if (score <= 8) return 'level-5';
if (score <= 10) return 'level-6';
if (score <= 14) return 'level-7';
if (score <= 16) return 'level-8';
if (score <= 19) return 'level-9';
return 'level-10';
}
let html = '';
// Rows from top (likelihood=5) to bottom (likelihood=1)
for (let li = 5; li >= 1; li--) {
// Y-axis header
html += `<div class="matrix-y-header">${li}</div>`;
// 5 cells per row (impact 1-5)
for (let imp = 1; imp <= 5; imp++) {
const score = li * imp;
const level = getCellLevel(score);
const key = `${li}-${imp}`;
const risksInCell = cellMap[key] || [];
const dot = risksInCell.length > 0
? `<div class="matrix-dot" title="${risksInCell.map(r => escapeHtml(r.title)).join(', ')}">${risksInCell.length}</div>`
: '';
html += `<div class="matrix-cell ${level}" title="P:${li} x I:${imp} = ${score}">${dot}</div>`;
}
}
// Bottom row: corner + x-axis headers
html += `<div class="matrix-corner"></div>`;
for (let imp = 1; imp <= 5; imp++) {
html += `<div class="matrix-x-header">${imp}</div>`;
}
grid.innerHTML = html;
}
// ── Detail View ──────────────────────────────────────────────
async function viewRiskDetail(id) {
try {
const result = await api.getRisk(id);
if (!result.success) {
showNotification(result.message || 'Errore nel caricamento', 'error');
return;
}
renderDetail(result.data);
} catch (e) {
showNotification('Errore di connessione', 'error');
}
}
function renderDetail(risk) {
document.getElementById('view-derived').classList.add('hidden');
document.getElementById('view-table').classList.add('hidden');
document.getElementById('view-matrix').classList.add('hidden');
const container = document.getElementById('view-detail');
container.classList.remove('hidden');
const score = risk.inherent_risk_score || 0;
const residualScore = risk.residual_risk_score || 0;
const categoryLabel = CATEGORY_LABELS[risk.category] || risk.category;
const treatmentLabel = TREATMENT_LABELS[risk.treatment] || risk.treatment;
let treatmentsHtml = '';
if (risk.treatments && risk.treatments.length > 0) {
risk.treatments.forEach(t => {
const tStatusBadge = getTreatmentStatusBadge(t.status);
treatmentsHtml += `
<div class="treatment-item">
<div class="treatment-item-header">
<strong>${escapeHtml(t.action_description)}</strong>
${tStatusBadge}
</div>
<div style="font-size:0.8rem; color:var(--gray-500);">
${t.responsible_name ? 'Responsabile: ' + escapeHtml(t.responsible_name) : ''}
${t.due_date ? ' | Scadenza: ' + formatDate(t.due_date) : ''}
</div>
${t.notes ? '<p style="font-size:0.85rem; margin-top:8px;">' + escapeHtml(t.notes) + '</p>' : ''}
</div>`;
});
} else {
treatmentsHtml = '<p class="text-muted" style="font-size:0.85rem;">Nessun trattamento registrato.</p>';
}
container.innerHTML = `
<button class="detail-back" onclick="backToList()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd"/></svg>
Torna alla lista
</button>
<div class="detail-grid">
<div>
<div class="card mb-24">
<div class="card-header">
<h3>
<code style="color:var(--primary); margin-right:8px;">${escapeHtml(risk.risk_code || '')}</code>
${escapeHtml(risk.title)}
</h3>
<div class="btn-group">
<button class="btn btn-secondary btn-sm" onclick="showRiskModal(${risk.id})">Modifica</button>
<button class="btn btn-danger btn-sm" onclick="confirmDeleteRisk(${risk.id}, '${escapeHtml(risk.title)}')">Elimina</button>
</div>
</div>
<div class="card-body">
<div class="score-display">
<div class="score-item">
<div class="score-item-value">${risk.likelihood || '-'}</div>
<div class="score-item-label">Probabilita'</div>
</div>
<div class="score-item" style="font-size:1.5rem; color:var(--gray-400); padding-top:4px;">x</div>
<div class="score-item">
<div class="score-item-value">${risk.impact || '-'}</div>
<div class="score-item-label">Impatto</div>
</div>
<div class="score-item" style="font-size:1.5rem; color:var(--gray-400); padding-top:4px;">=</div>
<div class="score-item">
<div class="score-item-value" style="color:${getScoreColorForRisk(score)}">${score}</div>
<div class="score-item-label">Rischio Inerente</div>
</div>
${residualScore > 0 ? `
<div class="score-item" style="border-left:2px solid var(--gray-200); padding-left:24px;">
<div class="score-item-value" style="color:${getScoreColorForRisk(residualScore)}">${residualScore}</div>
<div class="score-item-label">Rischio Residuo</div>
</div>` : ''}
</div>
${risk.description ? `
<div class="detail-field">
<div class="detail-field-label">Descrizione</div>
<div class="detail-field-value">${escapeHtml(risk.description)}</div>
</div>` : ''}
<div class="form-row">
<div class="detail-field">
<div class="detail-field-label">Categoria</div>
<div class="detail-field-value"><span class="tag">${escapeHtml(categoryLabel)}</span></div>
</div>
<div class="detail-field">
<div class="detail-field-label">Stato</div>
<div class="detail-field-value">${getStatusBadge(risk.status)}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Trattamento</div>
<div class="detail-field-value"><span class="badge badge-primary">${escapeHtml(treatmentLabel)}</span></div>
</div>
</div>
<div class="form-row">
${risk.threat_source ? `
<div class="detail-field">
<div class="detail-field-label">Sorgente Minaccia</div>
<div class="detail-field-value">${escapeHtml(risk.threat_source)}</div>
</div>` : ''}
${risk.vulnerability ? `
<div class="detail-field">
<div class="detail-field-label">Vulnerabilita'</div>
<div class="detail-field-value">${escapeHtml(risk.vulnerability)}</div>
</div>` : ''}
</div>
<div class="form-row">
${risk.owner_name ? `
<div class="detail-field">
<div class="detail-field-label">Responsabile</div>
<div class="detail-field-value">${escapeHtml(risk.owner_name)}</div>
</div>` : ''}
${risk.review_date ? `
<div class="detail-field">
<div class="detail-field-label">Data Revisione</div>
<div class="detail-field-value">${formatDate(risk.review_date)}</div>
</div>` : ''}
${risk.nis2_article ? `
<div class="detail-field">
<div class="detail-field-label">Articolo NIS2</div>
<div class="detail-field-value">${escapeHtml(risk.nis2_article)}</div>
</div>` : ''}
</div>
</div>
</div>
<!-- Treatments -->
<div class="card">
<div class="card-header">
<h3>Trattamenti</h3>
<button class="btn btn-sm btn-primary" onclick="showAddTreatmentModal(${risk.id})">+ Aggiungi</button>
</div>
<div class="card-body">
${treatmentsHtml}
</div>
</div>
</div>
<div>
<div class="card mb-24">
<div class="card-header"><h3>Riepilogo</h3></div>
<div class="card-body">
<div class="detail-field">
<div class="detail-field-label">Codice</div>
<div class="detail-field-value"><code>${escapeHtml(risk.risk_code || '-')}</code></div>
</div>
<div class="detail-field">
<div class="detail-field-label">Creato il</div>
<div class="detail-field-value">${formatDateTime(risk.created_at)}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Aggiornato il</div>
<div class="detail-field-value">${formatDateTime(risk.updated_at)}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Trattamenti</div>
<div class="detail-field-value">${risk.treatments ? risk.treatments.length : 0}</div>
</div>
</div>
</div>
</div>
</div>
`;
}
function getTreatmentStatusBadge(status) {
const map = { planned: 'badge-info', in_progress: 'badge-warning', completed: 'badge-success', cancelled: 'badge-neutral' };
return `<span class="badge ${map[status] || 'badge-neutral'}">${escapeHtml(TREATMENT_STATUS_LABELS[status] || status)}</span>`;
}
function backToList() {
document.getElementById('view-detail').classList.add('hidden');
// Sezione derivati, tabella custom e matrice sono sempre visibili.
document.getElementById('view-derived').classList.remove('hidden');
document.getElementById('view-table').classList.remove('hidden');
document.getElementById('view-matrix').classList.remove('hidden');
}
// ── Create/Edit Risk Modal ───────────────────────────────────
async function showRiskModal(riskId = null) {
let risk = {};
if (riskId) {
try {
const result = await api.getRisk(riskId);
if (result.success) risk = result.data;
} catch (e) { /* use defaults */ }
}
const isEdit = !!riskId;
const title = isEdit ? 'Modifica Rischio' : 'Nuovo Rischio';
const likelihood = risk.likelihood || 3;
const impact = risk.impact || 3;
const initialScore = likelihood * impact;
const content = `
<div class="form-group">
<label for="risk-title" class="form-label">Titolo <span class="required">*</span></label>
<input type="text" class="form-input" id="risk-title" value="${escapeHtml(risk.title || '')}" placeholder="Titolo del rischio">
</div>
<div class="form-group">
<label for="risk-description" class="form-label">Descrizione</label>
<textarea class="form-textarea" id="risk-description" rows="3" placeholder="Descrizione dettagliata">${escapeHtml(risk.description || '')}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="risk-category" class="form-label">Categoria</label>
<select class="form-select" id="risk-category">
<option value="cyber" ${risk.category==='cyber'?'selected':''}>Cyber</option>
<option value="operational" ${risk.category==='operational'?'selected':''}>Operativo</option>
<option value="compliance" ${risk.category==='compliance'?'selected':''}>Compliance</option>
<option value="supply_chain" ${risk.category==='supply_chain'?'selected':''}>Supply Chain</option>
<option value="physical" ${risk.category==='physical'?'selected':''}>Fisico</option>
<option value="human" ${risk.category==='human'?'selected':''}>Umano</option>
</select>
</div>
<div class="form-group">
<label for="risk-treatment" class="form-label">Trattamento</label>
<select class="form-select" id="risk-treatment">
<option value="mitigate" ${risk.treatment==='mitigate'?'selected':''}>Mitigare</option>
<option value="accept" ${risk.treatment==='accept'?'selected':''}>Accettare</option>
<option value="transfer" ${risk.treatment==='transfer'?'selected':''}>Trasferire</option>
<option value="avoid" ${risk.treatment==='avoid'?'selected':''}>Evitare</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="risk-threat-source" class="form-label">Sorgente Minaccia</label>
<input type="text" class="form-input" id="risk-threat-source" value="${escapeHtml(risk.threat_source || '')}" placeholder="Es. attaccante esterno">
</div>
<div class="form-group">
<label for="risk-vulnerability" class="form-label">Vulnerabilita'</label>
<input type="text" class="form-input" id="risk-vulnerability" value="${escapeHtml(risk.vulnerability || '')}" placeholder="Es. software non aggiornato">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="risk-likelihood" class="form-label">Probabilita' <span style="color:var(--gray-400); font-size:0.75rem; font-weight:400;">(frequenza stimata annua)</span></label>
<div class="range-value-display" id="likelihood-value">${likelihood}</div>
<input type="range" class="form-range" id="risk-likelihood" min="1" max="5" value="${likelihood}" oninput="updateRiskScore()">
<div style="font-size:0.75rem; color:var(--gray-500); margin-top:4px; padding:6px 8px; background:var(--gray-50); border-radius:4px;" id="likelihood-detail">${LIKELIHOOD_DETAILS[likelihood]}</div>
</div>
<div class="form-group">
<label for="risk-impact" class="form-label">Impatto <span style="color:var(--gray-400); font-size:0.75rem; font-weight:400;">(danno economico / utenti)</span></label>
<div class="range-value-display" id="impact-value">${impact}</div>
<input type="range" class="form-range" id="risk-impact" min="1" max="5" value="${impact}" oninput="updateRiskScore()">
<div style="font-size:0.75rem; color:var(--gray-500); margin-top:4px; padding:6px 8px; background:var(--gray-50); border-radius:4px;" id="impact-detail">${IMPACT_DETAILS[impact]}</div>
</div>
</div>
<div class="score-preview" id="score-preview">
<div class="score-preview-label">Rischio Inerente</div>
<div class="score-preview-value" id="score-preview-value" style="color:${getScoreColorForRisk(initialScore)}">${initialScore}</div>
</div>
<div class="form-row" style="margin-top:16px;">
<div class="form-group">
<label for="risk-owner" class="form-label">Responsabile (ID utente)</label>
<input type="text" class="form-input" id="risk-owner" value="${escapeHtml(risk.owner_user_id || '')}" placeholder="ID utente responsabile">
</div>
<div class="form-group">
<label for="risk-review-date" class="form-label">Data Revisione</label>
<input type="date" class="form-input" id="risk-review-date" value="${risk.review_date ? risk.review_date.split(' ')[0] : ''}">
</div>
</div>
<div class="form-group">
<label for="risk-nis2-article" class="form-label">Articolo NIS2 di riferimento</label>
<input type="text" class="form-input" id="risk-nis2-article" value="${escapeHtml(risk.nis2_article || '')}" placeholder="Es. Art. 21, par. 2">
</div>
`;
showModal(title, content, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="saveRisk(${riskId || 'null'})">${isEdit ? 'Salva Modifiche' : 'Crea Rischio'}</button>
`
});
}
function updateRiskScore() {
const l = parseInt(document.getElementById('risk-likelihood').value);
const i = parseInt(document.getElementById('risk-impact').value);
const score = l * i;
document.getElementById('likelihood-value').textContent = l;
document.getElementById('impact-value').textContent = i;
const lDetail = document.getElementById('likelihood-detail');
const iDetail = document.getElementById('impact-detail');
if (lDetail) lDetail.textContent = LIKELIHOOD_DETAILS[l];
if (iDetail) iDetail.textContent = IMPACT_DETAILS[i];
document.getElementById('score-preview-value').textContent = score;
document.getElementById('score-preview-value').style.color = getScoreColorForRisk(score);
}
async function saveRisk(riskId) {
const btn = document.querySelector('#modal-overlay .btn-primary');
const data = {
title: document.getElementById('risk-title').value.trim(),
description: document.getElementById('risk-description').value.trim(),
category: document.getElementById('risk-category').value,
treatment: document.getElementById('risk-treatment').value,
threat_source: document.getElementById('risk-threat-source').value.trim(),
vulnerability: document.getElementById('risk-vulnerability').value.trim(),
likelihood: parseInt(document.getElementById('risk-likelihood').value),
impact: parseInt(document.getElementById('risk-impact').value),
owner_user_id: document.getElementById('risk-owner').value.trim() || null,
review_date: document.getElementById('risk-review-date').value || null,
nis2_article: document.getElementById('risk-nis2-article').value.trim(),
};
if (!data.title) {
showNotification('Il titolo e\' obbligatorio', 'warning');
return;
}
setButtonLoading(btn, true);
try {
let result;
if (riskId) {
result = await api.updateRisk(riskId, data);
} else {
result = await api.createRisk(data);
}
if (result.success) {
closeModal();
showNotification(riskId ? 'Rischio aggiornato' : 'Rischio creato con successo', 'success');
await loadRisks();
loadMatrix();
// If detail view is visible, reload detail
if (!document.getElementById('view-detail').classList.contains('hidden')) {
viewRiskDetail(riskId || result.data.id);
}
} else {
setButtonLoading(btn, false);
showNotification(result.message || 'Errore nel salvataggio', 'error');
}
} catch (e) {
setButtonLoading(btn, false);
showNotification('Errore di connessione', 'error');
}
}
// ── Delete Risk ──────────────────────────────────────────────
function confirmDeleteRisk(id, title) {
showModal('Conferma Eliminazione', `
<p>Sei sicuro di voler eliminare il rischio <strong>${escapeHtml(title)}</strong>?</p>
<p class="text-muted" style="margin-top:8px; font-size:0.85rem;">Questa azione non puo' essere annullata.</p>
`, {
size: 'sm',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-danger" onclick="deleteRisk(${id})">Elimina</button>
`
});
}
async function deleteRisk(id) {
try {
const result = await api.deleteRisk(id);
if (result.success) {
closeModal();
showNotification('Rischio eliminato', 'success');
backToList();
await loadRisks();
loadMatrix();
} else {
showNotification(result.message || 'Errore nella eliminazione', 'error');
}
} catch (e) {
showNotification('Errore di connessione', 'error');
}
}
// ── Add Treatment Modal ──────────────────────────────────────
function showAddTreatmentModal(riskId) {
const content = `
<div class="form-group">
<label for="treatment-description" class="form-label">Descrizione Azione <span class="required">*</span></label>
<textarea class="form-textarea" id="treatment-description" rows="3" placeholder="Descrizione dell'azione di trattamento"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="treatment-responsible" class="form-label">Responsabile (ID utente)</label>
<input type="text" class="form-input" id="treatment-responsible" placeholder="ID utente">
</div>
<div class="form-group">
<label for="treatment-due-date" class="form-label">Data Scadenza</label>
<input type="date" class="form-input" id="treatment-due-date">
</div>
</div>
<div class="form-group">
<label for="treatment-status" class="form-label">Stato</label>
<select class="form-select" id="treatment-status">
<option value="planned">Pianificato</option>
<option value="in_progress">In Corso</option>
<option value="completed">Completato</option>
</select>
</div>
<div class="form-group">
<label for="treatment-notes" class="form-label">Note</label>
<textarea class="form-textarea" id="treatment-notes" rows="2" placeholder="Note aggiuntive"></textarea>
</div>
`;
showModal('Aggiungi Trattamento', content, {
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="saveTreatment(${riskId})">Aggiungi</button>
`
});
}
async function saveTreatment(riskId) {
const data = {
action_description: document.getElementById('treatment-description').value.trim(),
responsible_user_id: document.getElementById('treatment-responsible').value.trim() || null,
due_date: document.getElementById('treatment-due-date').value || null,
status: document.getElementById('treatment-status').value,
notes: document.getElementById('treatment-notes').value.trim(),
};
if (!data.action_description) {
showNotification('La descrizione dell\'azione e\' obbligatoria', 'warning');
return;
}
try {
const result = await api.post(`/risks/${riskId}/treatments`, data);
if (result.success) {
closeModal();
showNotification('Trattamento aggiunto', 'success');
viewRiskDetail(riskId);
} else {
showNotification(result.message || 'Errore nel salvataggio', 'error');
}
} catch (e) {
showNotification('Errore di connessione', 'error');
}
}
// ── AI Suggest ───────────────────────────────────────────────
async function aiSuggest() {
const btn = document.getElementById('btn-ai-suggest');
setButtonLoading(btn, true);
showNotification('Generazione suggerimenti AI in corso...', 'info');
try {
const result = await api.aiSuggestRisks();
if (result.success && result.data) {
const suggestions = Array.isArray(result.data) ? result.data : (result.data.suggestions || []);
if (suggestions.length === 0) {
showNotification('Nessun suggerimento disponibile', 'info');
return;
}
let listHtml = '<div style="max-height:400px; overflow-y:auto;">';
suggestions.forEach((s, idx) => {
listHtml += `
<div style="padding:12px; border:1px solid var(--gray-200); border-radius:var(--border-radius); margin-bottom:8px;">
<strong>${escapeHtml(s.title || 'Rischio ' + (idx + 1))}</strong>
<p style="font-size:0.85rem; color:var(--gray-600); margin:4px 0;">${escapeHtml(s.description || '')}</p>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-top:6px;">
${s.category ? `<span class="tag">${escapeHtml(CATEGORY_LABELS[s.category] || s.category)}</span>` : ''}
${s.likelihood ? `<span class="badge badge-info">P:${s.likelihood}</span>` : ''}
${s.impact ? `<span class="badge badge-warning">I:${s.impact}</span>` : ''}
</div>
</div>`;
});
listHtml += '</div>';
showModal('Rischi Suggeriti dall\'AI', listHtml, {
size: 'lg',
footer: `<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>`
});
} else {
showNotification(result.message || 'Errore AI', 'error');
}
} catch (e) {
showNotification('Errore di connessione al servizio AI', 'error');
} finally {
setButtonLoading(btn, false);
}
}
</script>
</body>
</html>