[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:
co-authored by
Claude Opus 4.8
parent
f1156a81bb
commit
f6d2076684
+183
-19
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user