Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1825 lines
104 KiB
HTML
1825 lines
104 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="it">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>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;
|
||
}
|
||
/* Bande di rischio (spec Simon): 1-2 basso verde · 3-8 medio arancione · 9-12 alto rosso tenue · 15-25 critico rosso */
|
||
.matrix-cell.band-low { background: #bbf7d0; }
|
||
.matrix-cell.band-med { background: #fdba74; }
|
||
.matrix-cell.band-high { background: #f87171; }
|
||
.matrix-cell.band-crit { background: #dc2626; }
|
||
/* Toggle Inerente/Residuo sopra ciascuna matrice */
|
||
.mx-toggle { display:inline-flex; border:1px solid var(--gray-300); border-radius:8px; overflow:hidden; }
|
||
.mx-toggle button { border:0; background:#fff; padding:6px 14px; font-size:0.8rem; font-weight:700; color:var(--gray-600); cursor:pointer; }
|
||
.mx-toggle button.on { background:var(--primary); color:#fff; }
|
||
.mx-head { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:10px; }
|
||
|
||
.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,
|
||
#view-table .table-container.scroll-box {
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
}
|
||
#view-derived .table-container.scroll-box thead th,
|
||
#view-table .table-container.scroll-box thead th {
|
||
position: sticky;
|
||
background: var(--gray-50);
|
||
z-index: 2;
|
||
}
|
||
/* Tabella 12 colonne (spec Simon): 2 righe di intestazione, gruppi inerente/residuo */
|
||
table.derived-12 thead tr:first-child th { top: 0; }
|
||
table.derived-12 thead tr:nth-child(2) th { top: 30px; }
|
||
table.derived-12 th, table.derived-12 td {
|
||
font-size: 0.8rem; padding: 6px 8px; text-align: center; vertical-align: middle;
|
||
}
|
||
table.derived-12 th.grp {
|
||
font-size: 0.66rem; letter-spacing: 0.04em; font-weight: 700; color: #fff;
|
||
}
|
||
table.derived-12 th.grp-inh { background: #b45309; }
|
||
table.derived-12 th.grp-res { background: #0e7490; }
|
||
table.derived-12 td.cell-code, table.derived-12 td.cell-desc { text-align: left; }
|
||
.rk-eval { display:inline-flex; align-items:center; justify-content:center; min-width:26px; height:22px;
|
||
padding:0 6px; border-radius:6px; font-weight:800; font-size:0.78rem; color:#fff; }
|
||
.rk-low { background:#16a34a; } .rk-med { background:#d97706; }
|
||
.rk-high { background:#ea580c; } .rk-crit { background:#dc2626; }
|
||
.rk-sel { width:52px; padding:2px 4px; font-size:0.78rem; border:1px solid var(--gray-300);
|
||
border-radius:6px; background:#fff; text-align:center; }
|
||
.rk-sel:focus { border-color: var(--primary); outline:none; }
|
||
.alert-btn { border:0; border-radius:16px; padding:4px 10px; font-size:0.7rem; font-weight:800;
|
||
letter-spacing:.02em; cursor:pointer; white-space:nowrap; }
|
||
.alert-pri { background:#dc2626; color:#fff; }
|
||
.alert-cri { background:#f87171; color:#fff; }
|
||
.alert-nec { background:#f59e0b; color:#fff; }
|
||
.alert-sug { background:#16a34a; color:#fff; }
|
||
.flag-riv { display:inline-block; padding:3px 9px; border-radius:14px; font-size:0.7rem; font-weight:800;
|
||
background:#fee2e2; color:#b91c1c; }
|
||
.flag-adeg { display:inline-block; padding:3px 9px; border-radius:14px; font-size:0.7rem; font-weight:800;
|
||
background:#dcfce7; color:#15803d; }
|
||
.rk-valutato { font-size:0.72rem; font-weight:700; color:#15803d; }
|
||
.rk-empty { color: var(--gray-300); }
|
||
.act-row { display:flex; align-items:flex-start; gap:10px; padding:10px 4px; border-bottom:1px solid var(--gray-100); }
|
||
.act-row .act-when { font-size:0.72rem; color:var(--gray-500); white-space:nowrap; min-width:74px; }
|
||
.act-row .act-desc { flex:1; font-size:0.82rem; color:var(--gray-800); }
|
||
.act-row .act-st { font-size:0.68rem; font-weight:700; text-transform:uppercase; color:var(--gray-500); }
|
||
/* ── Sezione "Rischi trasversali (all-hazards)" comprimibile (#501 p3) ── */
|
||
details.allhazard { margin: 4px 0 8px; }
|
||
details.allhazard > summary {
|
||
list-style: none; cursor: pointer; display: flex; align-items: center; gap: 10px;
|
||
padding: 12px 14px; border: 1px solid var(--gray-200, #e4e9f1); border-radius: 10px;
|
||
background: var(--gray-50, #f7f9fc); font-weight: 700; color: var(--gray-800, #1e2e46);
|
||
font-size: 1.02rem;
|
||
}
|
||
details.allhazard > summary::-webkit-details-marker { display: none; }
|
||
details.allhazard > summary:hover { background: #eef2f8; }
|
||
details.allhazard .ah-caret { display: inline-flex; color: var(--gray-500, #7382a0); transition: transform .15s; }
|
||
details.allhazard[open] > summary .ah-caret { transform: rotate(90deg); }
|
||
details.allhazard .ah-title { flex: 1; min-width: 0; }
|
||
details.allhazard .ah-count {
|
||
display: inline-flex; align-items: center; justify-content: center; min-width: 26px; height: 22px;
|
||
padding: 0 8px; border-radius: 20px; background: #e5edfb; color: #1f529e; font-size: 0.8rem; font-weight: 800;
|
||
}
|
||
details.allhazard .ah-count.empty { background: #fdeaea; color: #c0392b; }
|
||
</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>
|
||
<!-- I tasti AI Suggerisci / Nuovo Rischio sono in testa alla tabella "Rischi trasversali" (3.3) -->
|
||
</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 class="derived-12">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col" rowspan="2" data-i18n="risks.col_code">Codice rischio</th>
|
||
<th scope="col" rowspan="2" data-i18n="risks.col_risk">Descrizione rischio</th>
|
||
<th scope="colgroup" colspan="3" class="grp grp-inh" data-i18n="risks.grp_inherent">Rischio Inerente</th>
|
||
<th scope="col" rowspan="2" data-i18n="risks.col_alert">Alert</th>
|
||
<th scope="col" rowspan="2" data-i18n="risks.col_inherent">Rischio Inerente</th>
|
||
<th scope="colgroup" colspan="3" class="grp grp-res" data-i18n="risks.grp_residual">Rischio Residuo</th>
|
||
<th scope="col" rowspan="2" data-i18n="risks.col_res_outcome">Esito residuo</th>
|
||
<th scope="col" rowspan="2" data-i18n="risks.col_residual">Rischio Residuo</th>
|
||
</tr>
|
||
<tr>
|
||
<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_evaluation">Valutazione</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_evaluation">Valutazione</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="derived-table-body">
|
||
<tr>
|
||
<td colspan="12">
|
||
<div class="spinner" style="margin:40px auto;"></div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Matrice 5×5 dei RISCHI DAI REQUISITI (toggle Inerente/Residuo — punto 2) ── -->
|
||
<div id="view-matrix" style="margin-top:16px;">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="mx-head" style="width:100%;">
|
||
<div>
|
||
<h3 style="margin:0;">Matrice di Rischio 5×5 — Rischi dai requisiti</h3>
|
||
<span class="text-muted" style="font-size:0.8rem;">Esclusi i requisiti non applicabili alla classe</span>
|
||
</div>
|
||
<div class="mx-toggle" role="group" aria-label="Vista matrice rischi dai requisiti">
|
||
<button type="button" id="mxd-inh" class="on" onclick="setMatrixView('derived','inherent')">Inerente</button>
|
||
<button type="button" id="mxd-res" onclick="setMatrixView('derived','residual')">Residuo</button>
|
||
</div>
|
||
</div>
|
||
</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-derived">
|
||
<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-2)</div>
|
||
<div class="matrix-legend-item"><div class="matrix-legend-color" style="background:#fdba74;"></div>Medio (3-8)</div>
|
||
<div class="matrix-legend-item"><div class="matrix-legend-color" style="background:#f87171;"></div>Alto (9-12)</div>
|
||
<div class="matrix-legend-item"><div class="matrix-legend-color" style="background:#dc2626;"></div>Critico (15-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;">La matrice 5×5 è uno strumento di rappresentazione, non un obbligo normativo (art. 24 D.Lgs. 138/2024). Il tasto <b>Inerente/Residuo</b> mostra i giudizi prima o dopo il trattamento.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── SEZIONE 2 (in fondo): Rischi trasversali (multirischio / all-hazards) — accordion (#501 p3) ── -->
|
||
<details id="allhazard-section" class="allhazard" open>
|
||
<summary class="allhazard-summary">
|
||
<span class="ah-caret" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
|
||
</span>
|
||
<span class="ah-title" data-i18n="risks.custom_heading">Rischi trasversali (approccio multirischio / all-hazards) — art. 24 D.Lgs. 138/2024</span>
|
||
<span class="ah-count" id="allhazard-count" aria-hidden="true"></span>
|
||
</summary>
|
||
<p class="text-muted" style="font-size:0.82rem;margin:10px 0 16px;line-height:1.5;" data-i18n="risks.custom_help">Qui si registrano le minacce non riconducibili a un singolo requisito (ambiente fisico, catena di fornitura, eventi naturali, errore umano), come richiesto dall'approccio multirischio/all-hazards (art. 24 D.Lgs. 138/2024, che recepisce l'art. 21 §2 della Direttiva (UE) 2022/2555). Si distinguono dai «rischi di non conformità» della tabella qui sopra, che misurano il mancato o parziale soddisfacimento dei singoli requisiti.</p>
|
||
|
||
<!-- Table View (rischi trasversali) — tasti in testa (3.3) + tabella 12 colonne (3.2) -->
|
||
<div id="view-table">
|
||
<div class="mx-head" style="margin-bottom:12px;">
|
||
<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 class="filters-bar" style="margin:0;">
|
||
<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>
|
||
|
||
<div class="card">
|
||
<div class="table-container scroll-box">
|
||
<table class="derived-12">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col" rowspan="2">Codice rischio</th>
|
||
<th scope="col" rowspan="2">Descrizione rischio</th>
|
||
<th scope="colgroup" colspan="3" class="grp grp-inh">Rischio Inerente</th>
|
||
<th scope="col" rowspan="2">Alert</th>
|
||
<th scope="col" rowspan="2">Rischio Inerente</th>
|
||
<th scope="colgroup" colspan="3" class="grp grp-res">Rischio Residuo</th>
|
||
<th scope="col" rowspan="2">Esito residuo</th>
|
||
<th scope="col" rowspan="2">Rischio Residuo</th>
|
||
</tr>
|
||
<tr>
|
||
<th scope="col">Probabilità</th>
|
||
<th scope="col">Impatto</th>
|
||
<th scope="col">Valutazione</th>
|
||
<th scope="col">Probabilità</th>
|
||
<th scope="col">Impatto</th>
|
||
<th scope="col">Valutazione</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="risks-table-body">
|
||
<tr>
|
||
<td colspan="12">
|
||
<div class="spinner" style="margin:40px auto;"></div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Matrice 5×5 dei RISCHI TRASVERSALI (toggle Inerente/Residuo — punto 3.4) ── -->
|
||
<div id="view-matrix-trans" style="margin-top:18px;">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="mx-head" style="width:100%;">
|
||
<div>
|
||
<h3 style="margin:0;">Matrice di Rischio 5×5 — Rischi trasversali</h3>
|
||
<span class="text-muted" style="font-size:0.8rem;">Solo i rischi trasversali registrati sopra</span>
|
||
</div>
|
||
<div class="mx-toggle" role="group" aria-label="Vista matrice rischi trasversali">
|
||
<button type="button" id="mxt-inh" class="on" onclick="setMatrixView('trans','inherent')">Inerente</button>
|
||
<button type="button" id="mxt-res" onclick="setMatrixView('trans','residual')">Residuo</button>
|
||
</div>
|
||
</div>
|
||
</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-transversal">
|
||
<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-2)</div>
|
||
<div class="matrix-legend-item"><div class="matrix-legend-color" style="background:#fdba74;"></div>Medio (3-8)</div>
|
||
<div class="matrix-legend-item"><div class="matrix-legend-color" style="background:#f87171;"></div>Alto (9-12)</div>
|
||
<div class="matrix-legend-item"><div class="matrix-legend-color" style="background:#dc2626;"></div>Critico (15-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;">La matrice 5×5 è uno strumento di rappresentazione, non un obbligo normativo (art. 24 D.Lgs. 138/2024).</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- 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=20260730a"></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=20260731"></script>
|
||
<script src="/js/help.js?v=20260806b"></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';
|
||
}
|
||
|
||
// ── Livello del punteggio 1-25 (classe colore del riquadro "Valutazione") ──
|
||
function evalClass(p) {
|
||
if (p >= 15) return 'rk-crit';
|
||
if (p >= 9) return 'rk-high';
|
||
if (p >= 3) return 'rk-med';
|
||
return 'rk-low';
|
||
}
|
||
// ── Banda ALERT sul punteggio INERENTE (prodotto P×I). Soglie da spec Simon:
|
||
// 15-25 Prioritaria, 9-12 Critica, 3-8 Necessaria, 1-2 Suggerita.
|
||
// NB: con P,I in 1-5 i prodotti 7/11/13/14 NON esistono → nessun buco (conferma Simon). ──
|
||
function alertBand(p) {
|
||
if (p == null) return null;
|
||
if (p >= 15) return { cls: 'alert-pri', label: 'Azione Prioritaria' };
|
||
if (p >= 9) return { cls: 'alert-cri', label: 'Azione Critica' };
|
||
if (p >= 3) return { cls: 'alert-nec', label: 'Azione Necessaria' };
|
||
return { cls: 'alert-sug', label: 'Azione Suggerita' };
|
||
}
|
||
// ── Esito del RESIDUO (col.11). Eccezione #1: NON è un verdetto di conformità,
|
||
// è un flag di rivalutazione. ≥9 → "Rivalutare"; 1-8 → "Adeguato". ──
|
||
function residualOutcome(p) {
|
||
if (p == null) return null;
|
||
return p >= 9
|
||
? { cls: 'flag-riv', label: 'Rivalutare' }
|
||
: { cls: 'flag-adeg', label: 'Adeguato' };
|
||
}
|
||
function scoreSel(id, val, editable) {
|
||
if (!editable) return (val >= 1 && val <= 5) ? (val + '/5') : '<span class="rk-empty">—</span>';
|
||
let o = `<option value="">—</option>`;
|
||
for (let v = 1; v <= 5; v++) o += `<option value="${v}" ${v == val ? 'selected' : ''}>${v}</option>`;
|
||
return `<select class="rk-sel" id="${id}" onchange="onScoreChange(${id.split('-')[1]})">${o}</select>`;
|
||
}
|
||
|
||
function renderDerivedTable(rows) {
|
||
const tbody = document.getElementById('derived-table-body');
|
||
// Ticket #501/2: mostra SOLO i requisiti applicabili alla classe del soggetto.
|
||
rows = (rows || []).filter(isDerivedApplicable);
|
||
if (!rows || rows.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr><td colspan="12">
|
||
<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 reqId = r.requisito_id;
|
||
const editable = canEvaluate && r.stato !== 'non_applicabile';
|
||
const measureCode = r.misura_code || '-';
|
||
const reqCode = r.requisito_code || '';
|
||
const riskDescr = r.risk_descr || r.requisito_descr || '';
|
||
const st = DERIVED_STATUS_BADGE[r.stato] || DERIVED_STATUS_BADGE.da_valutare;
|
||
const riskTooltip = `Rischio di non conformità legato al requisito ${measureCode} ${reqCode}`.trim();
|
||
|
||
// INERENTE
|
||
const iL = (r.likelihood >= 1 && r.likelihood <= 5) ? parseInt(r.likelihood) : null;
|
||
const iI = (r.impact >= 1 && r.impact <= 5) ? parseInt(r.impact) : null;
|
||
const iP = (iL != null && iI != null) ? iL * iI : null;
|
||
// RESIDUO
|
||
const rL = (r.residual_likelihood >= 1 && r.residual_likelihood <= 5) ? parseInt(r.residual_likelihood) : null;
|
||
const rI = (r.residual_impact >= 1 && r.residual_impact <= 5) ? parseInt(r.residual_impact) : null;
|
||
const rP = (rL != null && rI != null) ? rL * rI : null;
|
||
|
||
const iEval = iP != null ? `<span class="rk-eval ${evalClass(iP)}" id="iv-${reqId}">${iP}</span>` : `<span class="rk-empty" id="iv-${reqId}">—</span>`;
|
||
const rEval = rP != null ? `<span class="rk-eval ${evalClass(rP)}" id="rv-${reqId}">${rP}</span>` : `<span class="rk-empty" id="rv-${reqId}">—</span>`;
|
||
|
||
const ab = alertBand(iP);
|
||
const alertCell = ab
|
||
? `<button class="alert-btn ${ab.cls}" onclick="openActionsPopup(${reqId})" title="Vedi/crea azioni">${ab.label}</button>`
|
||
: `<span class="rk-empty" id="al-${reqId}">—</span>`;
|
||
const inhOutcome = iP != null ? '<span class="rk-valutato">Valutato</span>' : '<span class="rk-empty">—</span>';
|
||
|
||
const ro = residualOutcome(rP);
|
||
const resOutcomeCell = ro ? `<span class="${ro.cls}">${ro.label}</span>` : '<span class="rk-empty">—</span>';
|
||
// Col.12 — Rischio Residuo: "Valutato" + stato requisito ESPLICITO (eccezione #1: non automatico).
|
||
const resCell = rP != null
|
||
? `<span class="rk-valutato">Valutato</span>${editable ? `<div style="margin-top:3px;"><a href="javascript:void(0)" onclick="openStateModal(${reqId})" style="font-size:0.68rem;color:var(--primary);">stato: ${escapeHtml(st.label)}</a></div>` : `<div style="font-size:0.68rem;color:var(--gray-500);margin-top:3px;">${escapeHtml(st.label)}</div>`}`
|
||
: '<span class="rk-empty">—</span>';
|
||
|
||
html += `
|
||
<tr>
|
||
<td class="cell-code">
|
||
<a href="misure-requisiti.html?req=${reqId}" title="Apri in Misure e Requisiti"><code style="font-size:0.75rem;color:var(--primary);">${escapeHtml(r.risk_code || '-')}</code></a>
|
||
<div style="font-size:0.66rem;color:var(--gray-500);margin-top:2px;">${escapeHtml(measureCode)} ${escapeHtml(reqCode)}</div>
|
||
</td>
|
||
<td class="cell-desc" title="${escapeHtml(riskTooltip)}">
|
||
<div style="font-size:0.64rem;text-transform:uppercase;letter-spacing:0.04em;font-weight:600;color:var(--gray-500);">Rischio di non conformità</div>
|
||
<div style="font-size:0.82rem;color:var(--gray-800);">${escapeHtml(riskDescr)}</div>
|
||
</td>
|
||
<td>${scoreSel('il-' + reqId, iL, editable)}</td>
|
||
<td>${scoreSel('ii-' + reqId, iI, editable)}</td>
|
||
<td>${iEval}</td>
|
||
<td>${alertCell}</td>
|
||
<td>${inhOutcome}</td>
|
||
<td>${scoreSel('rl-' + reqId, rL, editable)}</td>
|
||
<td>${scoreSel('ri-' + reqId, rI, editable)}</td>
|
||
<td>${rEval}</td>
|
||
<td>${resOutcomeCell}</td>
|
||
<td>${resCell}</td>
|
||
</tr>`;
|
||
});
|
||
tbody.innerHTML = html;
|
||
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
|
||
}
|
||
|
||
// ── Editing inline: legge i 4 punteggi della riga, salva l'intera valutazione, ri-render. ──
|
||
async function onScoreChange(reqId) {
|
||
const r = (derivedData || []).find(x => String(x.requisito_id) === String(reqId));
|
||
if (!r) return;
|
||
const rd = id => { const el = document.getElementById(id); if (!el) return null; const v = el.value; return v === '' ? null : parseInt(v); };
|
||
r.likelihood = rd('il-' + reqId);
|
||
r.impact = rd('ii-' + reqId);
|
||
r.score = (r.likelihood && r.impact) ? r.likelihood * r.impact : null;
|
||
r.residual_likelihood = rd('rl-' + reqId);
|
||
r.residual_impact = rd('ri-' + reqId);
|
||
r.residual_score = (r.residual_likelihood && r.residual_impact) ? r.residual_likelihood * r.residual_impact : null;
|
||
renderDerivedTable(derivedData);
|
||
loadMatrix();
|
||
try {
|
||
await api.frameworkSetState({
|
||
requisito_id: parseInt(reqId),
|
||
stato: r.stato || 'da_valutare',
|
||
valutazione_rischio: r.valutazione_rischio || '',
|
||
likelihood: r.likelihood, impact: r.impact,
|
||
residual_likelihood: r.residual_likelihood, residual_impact: r.residual_impact,
|
||
});
|
||
} catch (e) {
|
||
showNotification(e.message || 'Errore nel salvataggio', 'error');
|
||
}
|
||
}
|
||
|
||
// ── Popup Azioni (col.6 Alert): elenco azioni passate + "crea azione" ──
|
||
async function openActionsPopup(reqId) {
|
||
const r = (derivedData || []).find(x => String(x.requisito_id) === String(reqId));
|
||
if (!r) return;
|
||
let actions = [];
|
||
try {
|
||
const res = await api.requisitoActions(reqId);
|
||
if (res && res.success && res.data) actions = res.data.actions || [];
|
||
} catch (e) { /* mostra comunque il popup */ }
|
||
|
||
const today = new Date().toLocaleDateString('it-IT');
|
||
let list = `
|
||
<div class="act-row" style="background:var(--gray-50);border-radius:8px;">
|
||
<div class="act-when"><b>${today}</b></div>
|
||
<div class="act-desc"><input type="text" class="form-input" id="new-act-descr" placeholder="Descrivi la nuova azione…" style="width:100%;"></div>
|
||
<button class="btn btn-primary btn-sm" onclick="createReqAction(${reqId})">Crea azione</button>
|
||
</div>`;
|
||
if (actions.length) {
|
||
list += actions.map(a => {
|
||
const when = a.created_at ? new Date(a.created_at.replace(' ', 'T')).toLocaleDateString('it-IT') : '—';
|
||
return `<div class="act-row"><div class="act-when">${when}</div><div class="act-desc">${escapeHtml(a.description || '')}</div><div class="act-st">${escapeHtml(a.status || '')}</div></div>`;
|
||
}).join('');
|
||
} else {
|
||
list += `<div style="padding:14px 4px;color:var(--gray-500);font-size:0.82rem;">Nessuna azione precedente su questo rischio.</div>`;
|
||
}
|
||
|
||
const content = `
|
||
<div style="padding:10px 12px;background:var(--gray-50);border:1px solid var(--gray-200);border-radius:8px;margin-bottom:14px;">
|
||
<code style="font-size:0.78rem;color:var(--primary);">${escapeHtml(r.risk_code || '-')}</code>
|
||
<div style="font-size:0.82rem;color:var(--gray-800);margin-top:3px;">${escapeHtml(r.risk_descr || r.requisito_descr || '')}</div>
|
||
</div>
|
||
<p class="text-muted" style="font-size:0.74rem;margin:0 0 10px;">La soglia di trattamento è una scelta metodologica dell'organizzazione (rif. ISO/IEC 27001 §6.1.2), non un obbligo di legge.</p>
|
||
${list}`;
|
||
showModal('Azioni sul rischio', content, {
|
||
size: 'lg',
|
||
footer: `<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>`
|
||
});
|
||
}
|
||
|
||
async function createReqAction(reqId) {
|
||
const el = document.getElementById('new-act-descr');
|
||
const descr = (el && el.value || '').trim();
|
||
if (!descr) { showNotification('Inserisci una descrizione', 'error'); return; }
|
||
const r = (derivedData || []).find(x => String(x.requisito_id) === String(reqId));
|
||
try {
|
||
const res = await api.createRequisitoAction({ requisito_id: parseInt(reqId), description: descr, risk_code: r ? r.risk_code : null });
|
||
if (res && res.success) {
|
||
showNotification('Azione creata', 'success');
|
||
openActionsPopup(reqId);
|
||
} else {
|
||
showNotification((res && res.message) || 'Errore', 'error');
|
||
}
|
||
} catch (e) { showNotification(e.message || 'Errore', 'error'); }
|
||
}
|
||
|
||
// ── Aggiorna ESPLICITAMENTE lo stato di conformità del requisito (eccezione #1) ──
|
||
function openStateModal(reqId) {
|
||
const r = (derivedData || []).find(x => String(x.requisito_id) === String(reqId));
|
||
if (!r || !canEvaluate) return;
|
||
const opts = DERIVED_STATUS_OPTIONS.map(([v, l]) =>
|
||
`<option value="${v}" ${v === (r.stato || 'da_valutare') ? 'selected' : ''}>${escapeHtml(l)}</option>`).join('');
|
||
const content = `
|
||
<p class="text-muted" style="font-size:0.8rem;line-height:1.5;">Lo stato di <b>conformità</b> dipende dall'<b>attuazione della misura e dalle evidenze</b>, non dal punteggio di rischio. Aggiornalo qui in modo esplicito.</p>
|
||
<div class="form-group"><label class="form-label">Stato del requisito</label><select class="form-select" id="st-sel">${opts}</select></div>`;
|
||
showModal('Stato del requisito', content, {
|
||
size: 'md',
|
||
footer: `<button class="btn btn-secondary" onclick="closeModal()">Annulla</button><button class="btn btn-primary" onclick="saveState(${reqId})">Salva stato</button>`
|
||
});
|
||
}
|
||
async function saveState(reqId) {
|
||
const r = (derivedData || []).find(x => String(x.requisito_id) === String(reqId));
|
||
if (!r) return;
|
||
const stato = document.getElementById('st-sel').value;
|
||
try {
|
||
await api.frameworkSetState({
|
||
requisito_id: parseInt(reqId), stato: stato,
|
||
valutazione_rischio: r.valutazione_rischio || '',
|
||
likelihood: r.likelihood, impact: r.impact,
|
||
residual_likelihood: r.residual_likelihood, residual_impact: r.residual_impact,
|
||
});
|
||
r.stato = stato;
|
||
closeModal();
|
||
showNotification('Stato aggiornato', 'success');
|
||
renderDerivedTable(derivedData);
|
||
} catch (e) { showNotification(e.message || 'Errore', 'error'); }
|
||
}
|
||
|
||
// ── 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);
|
||
updateAllhazardHeader(risksData.length);
|
||
loadTransversalMatrix();
|
||
} else {
|
||
showNotification(result.message || 'Errore nel caricamento rischi', 'error');
|
||
}
|
||
} catch (e) {
|
||
showNotification('Errore di connessione', 'error');
|
||
}
|
||
}
|
||
|
||
// ── Header sezione "Rischi trasversali": contatore + apri-se-vuota (una volta).
|
||
// Normativo: la sezione resta sempre presente; se è VUOTA si apre da sola così
|
||
// in audit risulta presidiata, non nascosta (parere nis2-expert, #501 p3). ──
|
||
let _allhazardInit = false;
|
||
function updateAllhazardHeader(count) {
|
||
const badge = document.getElementById('allhazard-count');
|
||
const sec = document.getElementById('allhazard-section');
|
||
if (badge) {
|
||
badge.textContent = count;
|
||
badge.classList.toggle('empty', count === 0);
|
||
}
|
||
if (sec && !_allhazardInit) {
|
||
_allhazardInit = true;
|
||
sec.open = (count === 0); // vuota → aperta; con voci → chiusa (meno ingombro)
|
||
}
|
||
}
|
||
|
||
// ── Table Render ─────────────────────────────────────────────
|
||
function renderRisksTable(risks) {
|
||
const tbody = document.getElementById('risks-table-body');
|
||
|
||
if (!risks || risks.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr><td colspan="12">
|
||
<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 trasversale registrato</h4>
|
||
<p>Usa "Nuovo Rischio" o "AI Suggerisci" qui sopra per aggiungere minacce non legate a un singolo requisito.</p>
|
||
</div>
|
||
</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
const editable = canEvaluate;
|
||
let html = '';
|
||
risks.forEach(r => {
|
||
const id = r.id;
|
||
const iL = (r.likelihood >= 1 && r.likelihood <= 5) ? parseInt(r.likelihood) : null;
|
||
const iI = (r.impact >= 1 && r.impact <= 5) ? parseInt(r.impact) : null;
|
||
const iP = (iL != null && iI != null) ? iL * iI : null;
|
||
const rL = (r.residual_likelihood >= 1 && r.residual_likelihood <= 5) ? parseInt(r.residual_likelihood) : null;
|
||
const rI = (r.residual_impact >= 1 && r.residual_impact <= 5) ? parseInt(r.residual_impact) : null;
|
||
const rP = (rL != null && rI != null) ? rL * rI : null;
|
||
|
||
const iEval = iP != null ? `<span class="rk-eval ${evalClass(iP)}">${iP}</span>` : '<span class="rk-empty">—</span>';
|
||
const rEval = rP != null ? `<span class="rk-eval ${evalClass(rP)}">${rP}</span>` : '<span class="rk-empty">—</span>';
|
||
const ab = alertBand(iP);
|
||
const alertCell = ab
|
||
? `<button class="alert-btn ${ab.cls}" onclick="viewRiskDetail(${id})" title="Apri il rischio (trattamenti/azioni)">${ab.label}</button>`
|
||
: '<span class="rk-empty">—</span>';
|
||
const inhOutcome = iP != null ? '<span class="rk-valutato">Valutato</span>' : '<span class="rk-empty">—</span>';
|
||
const ro = residualOutcome(rP);
|
||
const resOutcomeCell = ro ? `<span class="${ro.cls}">${ro.label}</span>` : '<span class="rk-empty">—</span>';
|
||
const catLabel = CATEGORY_LABELS[r.category] || r.category || '';
|
||
const safeTitle = (r.title || '').replace(/['\\]/g, '');
|
||
const actBtns = editable
|
||
? `<div style="display:flex;gap:5px;margin-top:5px;">
|
||
<button class="btn btn-ghost btn-sm btn-icon" onclick="showRiskModal(${id})" title="Modifica"><svg viewBox="0 0 20 20" width="14" height="14" 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(${id}, '${escapeHtml(safeTitle)}')" title="Elimina rischio trasversale"><svg viewBox="0 0 20 20" width="14" height="14" 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>` : '';
|
||
const resCell = (rP != null ? '<span class="rk-valutato">Valutato</span>' : '<span class="rk-empty">—</span>') + actBtns;
|
||
|
||
html += `
|
||
<tr>
|
||
<td class="cell-code">
|
||
<a href="javascript:void(0)" onclick="viewRiskDetail(${id})" title="Apri il rischio"><code style="font-size:0.75rem;color:var(--primary);">${escapeHtml(r.risk_code || '-')}</code></a>
|
||
</td>
|
||
<td class="cell-desc">
|
||
<div style="font-size:0.64rem;text-transform:uppercase;letter-spacing:0.04em;font-weight:600;color:var(--gray-500);">${escapeHtml(catLabel)}</div>
|
||
<div style="font-size:0.82rem;color:var(--gray-800);">${escapeHtml(r.title || '')}</div>
|
||
</td>
|
||
<td>${scoreSelT('til-' + id, iL, editable)}</td>
|
||
<td>${scoreSelT('tii-' + id, iI, editable)}</td>
|
||
<td>${iEval}</td>
|
||
<td>${alertCell}</td>
|
||
<td>${inhOutcome}</td>
|
||
<td>${scoreSelT('trl-' + id, rL, editable)}</td>
|
||
<td>${scoreSelT('tri-' + id, rI, editable)}</td>
|
||
<td>${rEval}</td>
|
||
<td>${resOutcomeCell}</td>
|
||
<td>${resCell}</td>
|
||
</tr>`;
|
||
});
|
||
|
||
tbody.innerHTML = html;
|
||
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
|
||
}
|
||
|
||
// Select 1-5 inline per i rischi trasversali (salva su tabella risks via api.updateRisk).
|
||
function scoreSelT(id, val, editable) {
|
||
if (!editable) return (val >= 1 && val <= 5) ? (val + '/5') : '<span class="rk-empty">—</span>';
|
||
let o = `<option value="">—</option>`;
|
||
for (let v = 1; v <= 5; v++) o += `<option value="${v}" ${v == val ? 'selected' : ''}>${v}</option>`;
|
||
const rid = id.substring(id.indexOf('-') + 1);
|
||
return `<select class="rk-sel" id="${id}" onchange="onTransversalScoreChange(${rid})">${o}</select>`;
|
||
}
|
||
async function onTransversalScoreChange(riskId) {
|
||
const r = (risksData || []).find(x => String(x.id) === String(riskId));
|
||
if (!r) return;
|
||
const rd = elId => { const el = document.getElementById(elId); if (!el) return null; const v = el.value; return v === '' ? null : parseInt(v); };
|
||
r.likelihood = rd('til-' + riskId);
|
||
r.impact = rd('tii-' + riskId);
|
||
r.inherent_risk_score = (r.likelihood && r.impact) ? r.likelihood * r.impact : 0;
|
||
r.residual_likelihood = rd('trl-' + riskId);
|
||
r.residual_impact = rd('tri-' + riskId);
|
||
r.residual_risk_score = (r.residual_likelihood && r.residual_impact) ? r.residual_likelihood * r.residual_impact : 0;
|
||
renderRisksTable(risksData);
|
||
loadTransversalMatrix();
|
||
try {
|
||
await api.updateRisk(riskId, {
|
||
likelihood: r.likelihood || 0,
|
||
impact: r.impact || 0,
|
||
residual_likelihood: r.residual_likelihood,
|
||
residual_impact: r.residual_impact,
|
||
});
|
||
} catch (e) { showNotification(e.message || 'Errore nel salvataggio', 'error'); }
|
||
}
|
||
|
||
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.
|
||
// ── Matrici: vista (inerente/residuo) indipendente per ciascuna matrice (punti 2 e 3.4) ──
|
||
let matrixViewDerived = 'inherent';
|
||
let matrixViewTrans = 'inherent';
|
||
function setMatrixView(scope, view) {
|
||
if (scope === 'derived') {
|
||
matrixViewDerived = view;
|
||
const a = document.getElementById('mxd-inh'), b = document.getElementById('mxd-res');
|
||
if (a) a.classList.toggle('on', view === 'inherent');
|
||
if (b) b.classList.toggle('on', view === 'residual');
|
||
loadDerivedMatrix();
|
||
} else {
|
||
matrixViewTrans = view;
|
||
const a = document.getElementById('mxt-inh'), b = document.getElementById('mxt-res');
|
||
if (a) a.classList.toggle('on', view === 'inherent');
|
||
if (b) b.classList.toggle('on', view === 'residual');
|
||
loadTransversalMatrix();
|
||
}
|
||
}
|
||
function matrixPoints(list, view, titleFn, filterFn) {
|
||
const pts = [];
|
||
(list || []).forEach(r => {
|
||
if (filterFn && !filterFn(r)) return;
|
||
const l = view === 'residual' ? parseInt(r.residual_likelihood) : parseInt(r.likelihood);
|
||
const i = view === 'residual' ? parseInt(r.residual_impact) : parseInt(r.impact);
|
||
if (l >= 1 && l <= 5 && i >= 1 && i <= 5) pts.push({ likelihood: l, impact: i, title: titleFn(r) });
|
||
});
|
||
return pts;
|
||
}
|
||
function loadDerivedMatrix() {
|
||
const pts = matrixPoints(derivedData, matrixViewDerived,
|
||
r => ((r.risk_code || '') + ' ' + (r.requisito_code || '')).trim(),
|
||
r => isDerivedApplicable(r) && r.stato !== 'non_applicabile');
|
||
renderMatrix(pts, 'risk-matrix-grid-derived');
|
||
}
|
||
function loadTransversalMatrix() {
|
||
const pts = matrixPoints(risksData, matrixViewTrans, r => r.title || (r.risk_code || 'Rischio'));
|
||
renderMatrix(pts, 'risk-matrix-grid-transversal');
|
||
}
|
||
// Compat: le vecchie chiamate loadMatrix() aggiornano entrambe le matrici
|
||
function loadMatrix() { loadDerivedMatrix(); loadTransversalMatrix(); }
|
||
|
||
// Bande (spec Simon): 1-2 basso, 3-8 medio, 9-12 alto, 15-25 critico
|
||
function matrixBand(score) {
|
||
if (score >= 15) return 'band-crit';
|
||
if (score >= 9) return 'band-high';
|
||
if (score >= 3) return 'band-med';
|
||
return 'band-low';
|
||
}
|
||
function renderMatrix(risks, gridId) {
|
||
const grid = document.getElementById(gridId);
|
||
if (!grid) return;
|
||
const cellMap = {};
|
||
(risks || []).forEach(r => {
|
||
const key = `${r.likelihood || 0}-${r.impact || 0}`;
|
||
(cellMap[key] = cellMap[key] || []).push(r);
|
||
});
|
||
let html = '';
|
||
for (let li = 5; li >= 1; li--) {
|
||
html += `<div class="matrix-y-header">${li}</div>`;
|
||
for (let imp = 1; imp <= 5; imp++) {
|
||
const score = li * imp;
|
||
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 ${matrixBand(score)}" title="P:${li} x I:${imp} = ${score}">${dot}</div>`;
|
||
}
|
||
}
|
||
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-matrix').classList.add('hidden');
|
||
['allhazard-section', 'view-matrix-trans'].forEach(id => { const el = document.getElementById(id); if (el) el.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-matrix').classList.remove('hidden');
|
||
['allhazard-section', 'view-matrix-trans'].forEach(id => { const el = document.getElementById(id); if (el) el.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>
|