[FEAT] Gestione Rischi: tabella 12 colonne (inerente+residuo) — integrazione prototipo "Modulo Azioni" (Simon)

Integra la spec di Simon per la pagina Rischi con le correzioni normative
(parere nis2-expert + iso-27001-expert) applicate come eccezioni motivate:
- 12 colonne: Codice (link → Misure e Requisiti), Descrizione (RISCHIO DEFAULT =
  cfg_nis2_rischi.risk_descr), VALUTAZIONE RISCHIO INERENTE (P/I/PxI), Alert,
  Rischio Inerente, VALUTAZIONE RISCHIO RESIDUO (P/I/PxI), Esito residuo, Rischio Residuo.
- Editing INLINE di Probabilità/Impatto (1-5), salvataggio automatico.
- Alert a bande (15-25 Prioritaria / 9-14 Critica / 3-8 Necessaria / 1-2 Suggerita)
  → popup azioni + "crea azione" (tabella requisito_actions, seme Modulo Azioni).
- Eccezione #1: il rischio RESIDUO NON aggiorna in automatico lo STATO DI CONFORMITÀ del
  requisito (piani distinti). Col.11 "Esito residuo" = flag Rivalutare/Adeguato; conformità esplicita.
- Eccezione #3: banda 13-14 inclusa in "Critica" (9-14). "Danno €" → "Impatto" (1-5).
  Soglie = metodo interno (ISO 27001 §6.1.2), non obbligo (help/UI lo dichiarano).

Backend: mig 067; derivedList (+risk_descr +residuo), setState (+residuo), requisitoActions list/create.
help.js + i18n IT/EN. Cache-buster api/i18n/help 20260730a. Prossima mig=068.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-07-30 10:49:52 +02:00
co-authored by Claude Opus 4.8
parent cf224480cc
commit 8a0bc5ca39
49 changed files with 535 additions and 180 deletions
+243 -62
View File
@@ -306,10 +306,44 @@
}
#view-derived .table-container.scroll-box thead th {
position: sticky;
top: 0;
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); }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -367,23 +401,30 @@
</p>
<div class="card">
<div class="table-container scroll-box">
<table>
<table class="derived-12">
<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" 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">VALUTAZIONE 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">VALUTAZIONE 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_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">
<td colspan="12">
<div class="spinner" style="margin:40px auto;"></div>
</td>
</tr>
@@ -499,12 +540,12 @@
<!-- 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/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=20260729"></script>
<script src="/js/help.js?v=20260729b"></script>
<script src="/js/i18n.js?v=20260730a"></script>
<script src="/js/help.js?v=20260730a"></script>
<script>
// ── Auth & Init ──────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
@@ -634,16 +675,44 @@
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). Eccezione #3: la banda
// 13-14 (scoperta nella spec) è inclusa in "Critica" → 9-14 Critica, 15-25 Prioritaria. ──
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');
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).
// 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="${colspan}">
<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>
@@ -655,66 +724,178 @@
let html = '';
rows.forEach(r => {
const applicable = isDerivedApplicable(r);
const st = DERIVED_STATUS_BADGE[r.stato] || DERIVED_STATUS_BADGE.da_valutare;
const reqId = r.requisito_id;
const editable = canEvaluate && r.stato !== 'non_applicabile';
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();
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();
// 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>`;
// 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;
// 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>`;
}
}
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>`;
// 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>';
}
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 ${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>
<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>
<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 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>${conformityCell}</td>
${probCell}
${impCell}
${scoreCell}
<td>${evalVal}</td>
${actionsCell}
<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'],