[FEAT] Rischi #500 opzione B: matrice dai rischi-requisito (probabilità×impatto)

Ogni rischio derivato da un requisito puo' ora essere valutato con Probabilita' e
Impatto (1-5) e compare nella matrice 5x5.
- DB: mig 066 (org_requisito_state += likelihood/impact) + runner idempotente.
- Backend: FrameworkController::setState accetta likelihood/impact; RiskController
  ::derivedList ritorna likelihood/impact/score. Applicata su prod + reload.
- Frontend risks.html: colonne Probabilita'/Impatto/Rischio(P×I) + tasto "Valuta"
  (modale, riservato a super_admin/org_admin/compliance_manager); matrice calcolata
  client-side dai rischi derivati valutati (esclusi i non_applicabile, caveat
  nis2-expert) + rischi custom. Framing normativo invariato.
- help.js/i18n aggiornati; version 1.25.7; buster help/i18n ?v=20260727b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-07-27 15:32:59 +02:00
co-authored by Claude Opus 4.8
parent f1156a81bb
commit f6d2076684
42 changed files with 326 additions and 94 deletions
+183 -19
View File
@@ -362,7 +362,11 @@
<th scope="col" data-i18n="risks.col_risk">Rischio</th>
<th scope="col" data-i18n="risks.col_measure">Misura</th>
<th scope="col" data-i18n="risks.col_conformity">Conformità</th>
<th scope="col" data-i18n="risks.col_probability">Probabilità</th>
<th scope="col" data-i18n="risks.col_impact">Impatto</th>
<th scope="col" data-i18n="risks.col_risk_level">Rischio (P×I)</th>
<th scope="col" data-i18n="risks.col_evaluation">Valutazione</th>
<th scope="col" id="derived-th-actions" data-i18n="risks.col_actions" style="display:none;">Azioni</th>
</tr>
</thead>
<tbody id="derived-table-body">
@@ -435,7 +439,7 @@
<div class="card">
<div class="card-header">
<h3>Matrice di Rischio 5x5</h3>
<span class="text-muted" style="font-size:0.8rem;">Rischi inerenti (esclusi chiusi)</span>
<span class="text-muted" style="font-size:0.8rem;" data-i18n="risks.matrix_subtitle">Rischi da requisiti (valutati) + aggiuntivi — esclusi i non applicabili</span>
</div>
<div class="card-body">
<div class="risk-matrix-container">
@@ -487,8 +491,8 @@
<script src="/js/common.js?v=20260627t"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260627t"></script>
<script src="/js/i18n.js?v=20260727r"></script>
<script src="/js/help.js?v=20260727r"></script>
<script src="/js/i18n.js?v=20260727b"></script>
<script src="/js/help.js?v=20260727b"></script>
<script>
// ── Auth & Init ──────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
@@ -538,6 +542,9 @@
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() {
@@ -550,10 +557,25 @@
}
})();
// ── 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 ─────────────────────────────────────────────────────
loadDerivedRisks();
loadRisks();
loadMatrix();
// 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.
@@ -591,11 +613,21 @@
}
}
// Livello di rischio del punteggio derivato (P×I 1-25): opzione B.
// 1-4 basso/verde, 5-9 medio/giallo, 10-14 alto/arancio, 15-25 critico/rosso.
function getDerivedScoreClass(score) {
if (score >= 15) return 'score-critical';
if (score >= 10) return 'score-high';
if (score >= 5) return 'score-medium';
return 'score-low';
}
function renderDerivedTable(rows) {
const tbody = document.getElementById('derived-table-body');
const colspan = 8 + (canEvaluate ? 1 : 0);
if (!rows || rows.length === 0) {
tbody.innerHTML = `
<tr><td colspan="5">
<tr><td colspan="${colspan}">
<div class="empty-state">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944z" clip-rule="evenodd"/></svg>
<h4>Nessun requisito applicabile</h4>
@@ -623,6 +655,27 @@
? `<span class="badge ${st.cls}">${escapeHtml(st.label)}</span>`
: `<span class="badge badge-neutral">Non applicabile alla tua classe</span>`;
// Probabilità / Impatto / Rischio: solo per righe applicabili e non "non applicabili".
let probCell = '<td>—</td>', impCell = '<td>—</td>', scoreCell = '<td>—</td>';
if (applicable && r.stato !== 'non_applicabile') {
const l = (r.likelihood >= 1 && r.likelihood <= 5) ? parseInt(r.likelihood) : null;
const i = (r.impact >= 1 && r.impact <= 5) ? parseInt(r.impact) : null;
probCell = `<td>${l != null ? l + '/5' : '—'}</td>`;
impCell = `<td>${i != null ? i + '/5' : '—'}</td>`;
if (r.score != null && r.score !== '' && !isNaN(r.score)) {
const sc = parseInt(r.score);
scoreCell = `<td><span class="risk-score ${getDerivedScoreClass(sc)}" title="P${l != null ? l : '?'} × I${i != null ? i : '?'} = ${sc}">${sc}</span></td>`;
}
}
// Colonna Azioni (solo per i ruoli che possono valutare).
let actionsCell = '';
if (canEvaluate) {
actionsCell = applicable
? `<td><button class="btn btn-ghost btn-sm" onclick="openEvaluateModal(${r.requisito_id})" title="Valuta rischio"><i data-lucide="sliders-horizontal" class="ic"></i> Valuta</button></td>`
: '<td><span class="text-muted">—</span></td>';
}
html += `
<tr ${rowStyle}>
<td><code style="font-size:0.78rem;color:var(--primary);">${escapeHtml(r.risk_code || '-')}</code></td>
@@ -635,10 +688,107 @@
${measureDescr ? `<div style="font-size:0.78rem;color:var(--gray-500);margin-top:2px;">${escapeHtml(measureDescr)}</div>` : ''}
</td>
<td>${conformityCell}</td>
${probCell}
${impCell}
${scoreCell}
<td>${evalVal}</td>
${actionsCell}
</tr>`;
});
tbody.innerHTML = html;
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
}
// ── Modale "Valuta" (opzione B) — valutazione rischio-da-requisito ──
const DERIVED_STATUS_OPTIONS = [
['da_valutare', 'Da valutare'],
['non_applicabile', 'Non applicabile'],
['non_conforme', 'Non conforme'],
['parziale', 'Parziale'],
['conforme', 'Conforme'],
];
function scaleOptions(labels, cur) {
let o = '';
for (let v = 1; v <= 5; v++) {
o += `<option value="${v}" ${v == cur ? 'selected' : ''}>${v} - ${labels[v]}</option>`;
}
return o;
}
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 curL = (r.likelihood >= 1 && r.likelihood <= 5) ? parseInt(r.likelihood) : 3;
const curI = (r.impact >= 1 && r.impact <= 5) ? parseInt(r.impact) : 3;
const curStato = r.stato || 'da_valutare';
const curNote = r.valutazione_rischio || '';
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>
<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() {
@@ -734,17 +884,31 @@
}
// ── Matrix ───────────────────────────────────────────────────
async function loadMatrix() {
try {
const result = await api.getRiskMatrix();
if (result.success) {
renderMatrix(result.data.risks || []);
} else {
showNotification(result.message || 'Errore caricamento matrice', 'error');
// Opzione B: la matrice aggrega, CLIENT-SIDE, TUTTI i rischi:
// - rischi DERIVATI dai requisiti con probabilità+impatto valorizzati,
// ESCLUSI i "non applicabili" (stato non_applicabile o non applicabili alla classe);
// - rischi AGGIUNTIVI custom (che portano già likelihood/impact).
// Legge dagli array in memoria (derivedData/risksData): niente fetch qui.
function loadMatrix() {
const points = [];
(derivedData || []).forEach(r => {
if (!isDerivedApplicable(r)) return; // esclude i non applicabili alla classe
if (r.stato === 'non_applicabile') return; // esclude i non applicabili
const l = parseInt(r.likelihood), i = parseInt(r.impact);
if (l >= 1 && l <= 5 && i >= 1 && i <= 5) {
points.push({ likelihood: l, impact: i, title: ((r.risk_code || '') + ' ' + (r.requisito_code || '')).trim() });
}
} catch (e) {
showNotification('Errore di connessione', 'error');
}
});
(risksData || []).forEach(r => {
const l = parseInt(r.likelihood), i = parseInt(r.impact);
if (l >= 1 && l <= 5 && i >= 1 && i <= 5) {
points.push({ likelihood: l, impact: i, title: r.title || (r.risk_code || 'Rischio') });
}
});
renderMatrix(points);
}
function renderMatrix(risks) {
@@ -1144,7 +1308,7 @@
if (result.success) {
closeModal();
showNotification(riskId ? 'Rischio aggiornato' : 'Rischio creato con successo', 'success');
loadRisks();
await loadRisks();
loadMatrix();
// If detail view is visible, reload detail
if (!document.getElementById('view-detail').classList.contains('hidden')) {
@@ -1181,7 +1345,7 @@
closeModal();
showNotification('Rischio eliminato', 'success');
backToList();
loadRisks();
await loadRisks();
loadMatrix();
} else {
showNotification(result.message || 'Errore nella eliminazione', 'error');