[FEAT] Epic C / C1 operativo — valutazione/stato di conformità per requisito (mig.047)

org_requisito_state (org x requisito -> stato + valutazione del rischio + note),
la valutazione 'parte vuota'. È da qui che emerge lo stato di conformità (sostituirà il Gap ACN, C3).
- FrameworkController: catalog ora include lo stato per-org di ogni requisito (risoluzione org
  OPZIONALE con verifica accesso = anti-IDOR); nuovo POST /framework/state (upsert, requireOrgRole
  org_admin/compliance_manager, validazione stato + requisito esistente).
- misure-requisiti.html: colonna Stato/Valutazione (se org) + modale di valutazione (stato,
  valutazione del rischio, note). api.frameworkSetState.
Smoke prod: catalog has_org/org_class OK; upsert non_conforme persistito e riletto; ISOLAMENTO
multi-tenant verificato (org 152 non visibile a org 151). Additivo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-16 17:41:16 +02:00
co-authored by Claude Opus 4.8
parent 92d101b6f4
commit b34bf3f9f6
5 changed files with 286 additions and 38 deletions
+133 -36
View File
@@ -3,19 +3,18 @@
* NIS2 Agile - Framework canonico Misure/Requisiti (Epic C / C1) * NIS2 Agile - Framework canonico Misure/Requisiti (Epic C / C1)
* ---------------------------------------------------------------------------- * ----------------------------------------------------------------------------
* Espone in SOLA LETTURA l'elenco canonico delle misure di sicurezza di base e * Espone in SOLA LETTURA l'elenco canonico delle misure di sicurezza di base e
* dei relativi requisiti (Determinazione ACN n. 164179 del 14 aprile 2025), * dei relativi requisiti (Determinazione ACN n. 164179 del 14 aprile 2025), con
* con la procedura e il rischio di default associati a ciascun requisito. * la procedura e il rischio di default associati a ciascun requisito.
* *
* Fonte dati: tabelle di CONFIGURAZIONE cfg_nis2_* (system-level), popolate dai * Fonte dati: tabelle di CONFIGURAZIONE cfg_nis2_* (system-level), popolate dai
* file autoritativi forniti dal referente compliance (docs/simon/) — codifica * file autoritativi del referente compliance (docs/simon/) — codifica NON
* NON variabile. Il dato NON e' org-scoped (e' framework normativo condiviso); * variabile. La codifica e l'ordine NON sono modificabili dall'utente.
* l'unico contributo dell'org e' la sua CLASSE (essential/important), usata dal
* frontend per "spegnere" le righe non applicabili al soggetto.
* *
* Sola lettura: nessun endpoint di scrittura. L'utente NON puo' modificare la * STATO/VALUTAZIONE per-org (C1 operativo): per ogni requisito l'organizzazione
* codifica; la configurazione del framework e' riservata a super_admin/sistema * conserva la "valutazione del rischio" (che parte vuota) e lo stato di
* (gestione futura). Vista doppia (per misura / per area politica a..p) gestita * conformita' in org_requisito_state (mig.047). È da qui che emerge lo stato di
* lato frontend sugli stessi dati. * conformita' dell'azienda (sostituisce il modulo Gap ACN, C3). La codifica del
* framework resta intatta: si modifica SOLO il dato di valutazione dell'org.
* *
* NOTE strutturali: DB API Database::query/fetchAll/fetchOne (NON Database::execute). * NOTE strutturali: DB API Database::query/fetchAll/fetchOne (NON Database::execute).
*/ */
@@ -24,37 +23,72 @@ require_once __DIR__ . '/BaseController.php';
class FrameworkController extends BaseController class FrameworkController extends BaseController
{ {
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
private const STATI = ['da_valutare', 'non_applicabile', 'non_conforme', 'parziale', 'conforme'];
/** /**
* GET /api/framework/catalog * GET /api/framework/catalog
* Ritorna ambiti NIST, misure (con requisiti annidati, procedura+rischio di * Catalogo canonico (ambiti, misure con requisiti annidati, procedura+rischio
* default e flag di applicabilita' importanti/essenziali) e la classe dell'org * di default, flag importanti/essenziali) + lo STATO per-org di ogni requisito
* corrente (se disponibile). Ordine 'ord'/'n' = ordine di default immutabile. * (se e' presente un'org corrente) + la classe del soggetto.
* Ordine 'ord'/'n' = ordine di default immutabile.
*/ */
public function catalog(): void public function catalog(): void
{ {
$this->requireAuth(); $this->requireAuth();
// Classe del soggetto (per evidenziare/spegnere le righe) — opzionale. // Risoluzione org OPZIONALE (il catalogo è visibile anche senza org), con
// verifica d'accesso per non esporre lo stato di un'altra org (anti-IDOR).
$orgId = null;
$orgClass = null; $orgClass = null;
$orgId = $this->getCurrentOrgId(); if (!empty($this->isDemo)) {
$orgId = $this->demoOrgId ?? null;
} else {
$candidate = $this->resolveOrgId();
if ($candidate) {
if (($this->currentUser['role'] ?? '') === 'super_admin') {
$orgId = $candidate;
} else {
$member = Database::fetchOne(
'SELECT 1 FROM user_organizations WHERE user_id = ? AND organization_id = ?',
[$this->getCurrentUserId(), $candidate]
);
if ($member) { $orgId = $candidate; }
}
}
}
if ($orgId) { if ($orgId) {
$row = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$orgId]); $row = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$orgId]);
$orgClass = $row['entity_type'] ?? null; // essential | important | not_applicable $orgClass = $row['entity_type'] ?? null; // essential | important | not_applicable
} }
$ambiti = Database::fetchAll( // Stato per-org dei requisiti (mappa requisito_id => stato)
'SELECT nist_code, nist_descr FROM cfg_nis2_ambiti ORDER BY nist_code ASC' $stateMap = [];
); if ($orgId) {
foreach (Database::fetchAll(
'SELECT requisito_id, stato, valutazione_rischio, note, updated_at
FROM org_requisito_state WHERE organization_id = ?',
[$orgId]
) as $s) {
$stateMap[(int) $s['requisito_id']] = [
'stato' => $s['stato'],
'valutazione_rischio' => $s['valutazione_rischio'],
'note' => $s['note'],
'updated_at' => $s['updated_at'],
];
}
}
$ambiti = Database::fetchAll('SELECT nist_code, nist_descr FROM cfg_nis2_ambiti ORDER BY nist_code ASC');
$misure = Database::fetchAll( $misure = Database::fetchAll(
'SELECT misura_code, nist_code, area_politica, misura_descr, ord, 'SELECT misura_code, nist_code, area_politica, misura_descr, ord,
applies_important, applies_essential applies_important, applies_essential
FROM cfg_nis2_misure FROM cfg_nis2_misure ORDER BY ord ASC'
ORDER BY ord ASC'
); );
$reqs = Database::fetchAll( $reqs = Database::fetchAll(
'SELECT q.n, q.misura_code, q.requisito_code, q.requisito_descr, 'SELECT q.id, q.n, q.misura_code, q.requisito_code, q.requisito_descr,
q.proc_code, p.proc_descr, q.risk_code, r.risk_descr, q.proc_code, p.proc_descr, q.risk_code, r.risk_descr,
q.applies_important, q.applies_essential q.applies_important, q.applies_essential
FROM cfg_nis2_requisiti q FROM cfg_nis2_requisiti q
@@ -65,16 +99,22 @@ class FrameworkController extends BaseController
$byMis = []; $byMis = [];
foreach ($reqs as $q) { foreach ($reqs as $q) {
$rid = (int) $q['id'];
$st = $stateMap[$rid] ?? null;
$byMis[$q['misura_code']][] = [ $byMis[$q['misura_code']][] = [
'n' => $q['n'] !== null ? (int) $q['n'] : null, 'id' => $rid,
'requisito_code' => $q['requisito_code'], 'n' => $q['n'] !== null ? (int) $q['n'] : null,
'requisito_descr' => $q['requisito_descr'], 'requisito_code' => $q['requisito_code'],
'proc_code' => $q['proc_code'], 'requisito_descr' => $q['requisito_descr'],
'proc_descr' => $q['proc_descr'], 'proc_code' => $q['proc_code'],
'risk_code' => $q['risk_code'], 'proc_descr' => $q['proc_descr'],
'risk_descr' => $q['risk_descr'], 'risk_code' => $q['risk_code'],
'applies_important' => (int) $q['applies_important'], 'risk_descr' => $q['risk_descr'],
'applies_essential' => (int) $q['applies_essential'], 'applies_important' => (int) $q['applies_important'],
'applies_essential' => (int) $q['applies_essential'],
'stato' => $st['stato'] ?? 'da_valutare',
'valutazione_rischio' => $st['valutazione_rischio'] ?? null,
'note' => $st['note'] ?? null,
]; ];
} }
@@ -93,12 +133,69 @@ class FrameworkController extends BaseController
} }
$this->jsonSuccess([ $this->jsonSuccess([
'org_class' => $orgClass, 'org_class' => $orgClass,
'ambiti' => $ambiti, 'has_org' => $orgId !== null,
'measures' => $measures, 'stati' => self::STATI,
'totals' => ['misure' => count($measures), 'requisiti' => count($reqs)], 'ambiti' => $ambiti,
'readonly' => true, 'measures' => $measures,
'source' => 'Determinazione ACN n. 164179 del 14 aprile 2025', 'totals' => ['misure' => count($measures), 'requisiti' => count($reqs)],
'readonly' => true,
'source' => 'Determinazione ACN n. 164179 del 14 aprile 2025',
]); ]);
} }
/**
* POST /api/framework/state
* Upsert della valutazione/stato di UN requisito per l'org corrente.
* Body: {requisito_id*, stato?, valutazione_rischio?, note?}
* La codifica del framework NON e' toccata: si scrive solo org_requisito_state.
*/
public function setState(): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$orgId = $this->getCurrentOrgId();
$body = $this->getJsonBody();
$reqId = isset($body['requisito_id']) ? (int) $body['requisito_id'] : 0;
if ($reqId <= 0) { $this->jsonError('requisito_id mancante o non valido', 400); return; }
// anti-IDOR: il requisito deve esistere nel catalogo canonico
$exists = Database::fetchOne('SELECT id FROM cfg_nis2_requisiti WHERE id = ?', [$reqId]);
if (!$exists) { $this->jsonError('Requisito inesistente', 404); return; }
$stato = $body['stato'] ?? null;
if ($stato !== null && !in_array($stato, self::STATI, true)) {
$this->jsonError('Stato non valido', 400); return;
}
$valutazione = array_key_exists('valutazione_rischio', $body)
? ($body['valutazione_rischio'] !== '' ? (string) $body['valutazione_rischio'] : null) : null;
$note = array_key_exists('note', $body)
? ($body['note'] !== '' ? (string) $body['note'] : null) : null;
// upsert org-scoped (UNIQUE org+requisito)
Database::query(
'INSERT INTO org_requisito_state
(organization_id, requisito_id, stato, valutazione_rischio, note, updated_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
stato = VALUES(stato),
valutazione_rischio = VALUES(valutazione_rischio),
note = VALUES(note),
updated_by = VALUES(updated_by)',
[$orgId, $reqId, $stato ?? 'da_valutare', $valutazione, $note, $this->getCurrentUserId()]
);
$row = Database::fetchOne(
'SELECT requisito_id, stato, valutazione_rischio, note, updated_at
FROM org_requisito_state WHERE organization_id = ? AND requisito_id = ?',
[$orgId, $reqId]
);
$this->jsonSuccess([
'requisito_id' => (int) $row['requisito_id'],
'stato' => $row['stato'],
'valutazione_rischio' => $row['valutazione_rischio'],
'note' => $row['note'],
'updated_at' => $row['updated_at'],
], 'Valutazione salvata');
}
} }
+30
View File
@@ -0,0 +1,30 @@
-- =====================================================================
-- 047 — Stato/valutazione per-org di ogni REQUISITO (Epic C / C1 operativo)
-- =====================================================================
-- Per ogni organizzazione e per ogni requisito del framework (cfg_nis2_requisiti)
-- conserva la VALUTAZIONE DEL RISCHIO (che "parte vuota") e lo STATO di conformita'.
-- È da qui che emerge lo stato di conformita' dell'azienda (sostituisce il modulo
-- "Analisi GAP ACN", C3). Org-scoped, additivo, reversibile (DROP TABLE).
--
-- Runner-safe: solo CREATE TABLE IF NOT EXISTS (no DELIMITER/stored-proc).
-- =====================================================================
CREATE TABLE IF NOT EXISTS org_requisito_state (
id INT NOT NULL AUTO_INCREMENT,
organization_id INT NOT NULL,
requisito_id INT NOT NULL,
stato ENUM('da_valutare','non_applicabile','non_conforme','parziale','conforme')
NOT NULL DEFAULT 'da_valutare',
valutazione_rischio TEXT NULL,
note TEXT NULL,
updated_by INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_org_req (organization_id, requisito_id),
KEY idx_ors_org (organization_id),
KEY idx_ors_req (requisito_id),
CONSTRAINT fk_ors_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
CONSTRAINT fk_ors_req FOREIGN KEY (requisito_id) REFERENCES cfg_nis2_requisiti (id) ON DELETE CASCADE,
CONSTRAINT fk_ors_user FOREIGN KEY (updated_by) REFERENCES users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+1
View File
@@ -255,6 +255,7 @@ $actionMap = [
// rischio di default per requisito). Nessuna scrittura (codifica non variabile). // rischio di default per requisito). Nessuna scrittura (codifica non variabile).
'framework' => [ 'framework' => [
'GET:catalog' => 'catalog', 'GET:catalog' => 'catalog',
'POST:state' => 'setState', // upsert valutazione/stato per requisito (org-scoped)
], ],
// ── AssessmentController ──────────────────────── // ── AssessmentController ────────────────────────
+1
View File
@@ -294,6 +294,7 @@ class NIS2API {
// Catalogo cfg_nis2_* (43 misure / 116 requisiti + procedura+rischio default). // Catalogo cfg_nis2_* (43 misure / 116 requisiti + procedura+rischio default).
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
frameworkCatalog() { return this._acn(this.get('/framework/catalog')); } frameworkCatalog() { return this._acn(this.get('/framework/catalog')); }
frameworkSetState(d) { return this._acn(this.post('/framework/state', d || {})); }
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// Stakeholder estesi (A4 Fase 4.5) — riusa suppliers (GV.SC-02). // Stakeholder estesi (A4 Fase 4.5) — riusa suppliers (GV.SC-02).
+121 -2
View File
@@ -37,6 +37,30 @@
.mr-row.spento { opacity:.4; } .mr-row.spento { opacity:.4; }
.mr-row.spento td { font-style:italic; } .mr-row.spento td { font-style:italic; }
.mr-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); } .mr-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); }
.sv-badge { display:inline-block; font-size:.72rem; font-weight:800; padding:3px 10px; border-radius:20px; border:none; cursor:pointer; }
.sv-badge.sv-todo { background:#f1f5f9; color:#475569; }
.sv-badge.sv-na { background:#e5e7eb; color:#6b7280; }
.sv-badge.sv-ko { background:#fee2e2; color:#991b1b; }
.sv-badge.sv-mid { background:#fef3c7; color:#92400e; }
.sv-badge.sv-ok { background:#dcfce7; color:#166534; }
.sv-val { font-size:.76rem; color:#64748b; margin-top:4px; max-width:300px; }
/* Modale valutazione */
.rm-overlay { display:none; position:fixed; inset:0; background:rgba(15,23,42,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 16px; }
.rm-overlay.open { display:flex; }
.rm-dialog { background:#fff; border-radius:12px; width:100%; max-width:600px; box-shadow:0 20px 50px rgba(0,0,0,.25); }
.rm-head { display:flex; justify-content:space-between; align-items:flex-start; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
.rm-head h3 { margin:0; font-size:1.02rem; }
.rm-head .sub { font-size:.8rem; color:var(--gray-500,#6b7280); margin-top:4px; }
.rm-body { padding:18px 20px; }
.rm-foot { padding:14px 20px; border-top:1px solid var(--gray-100,#f3f4f6); display:flex; justify-content:flex-end; gap:10px; }
.rm-field { margin-bottom:14px; }
.rm-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; }
.rm-field select, .rm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; }
.rm-field textarea { min-height:80px; resize:vertical; }
.rm-ctx { background:#f8fafc; border:1px solid var(--gray-100,#f3f4f6); border-radius:8px; padding:10px 12px; font-size:.83rem; margin-bottom:14px; line-height:1.5; }
.rm-ctx .pill { display:inline-block; font-size:.7rem; font-weight:700; color:#3730a3; background:#eef2ff; border-radius:6px; padding:1px 6px; margin-right:4px; }
.rm-err { color:#b91c1c; font-size:.82rem; min-height:18px; }
.rm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); }
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -104,6 +128,40 @@
</main> </main>
</div> </div>
<!-- Modale valutazione del requisito (org-scoped) -->
<div class="rm-overlay" id="mr-modal" role="dialog" aria-modal="true" aria-labelledby="mr-modal-title">
<div class="rm-dialog">
<div class="rm-head">
<div><h3 id="mr-modal-title">Valutazione del requisito</h3><div class="sub" id="mr-modal-sub"></div></div>
<button class="rm-close" type="button" onclick="mrCloseEval()" aria-label="Chiudi">&times;</button>
</div>
<div class="rm-body">
<input type="hidden" id="mr-eval-id">
<div class="rm-ctx" id="mr-eval-ctx"></div>
<div class="rm-field"><label for="mr-eval-stato">Stato di conformità</label>
<select id="mr-eval-stato">
<option value="da_valutare">Da valutare</option>
<option value="non_applicabile">Non applicabile</option>
<option value="non_conforme">Non conforme</option>
<option value="parziale">Parziale</option>
<option value="conforme">Conforme</option>
</select>
</div>
<div class="rm-field"><label for="mr-eval-val">Valutazione del rischio</label>
<textarea id="mr-eval-val" maxlength="4000" placeholder="Descrivi la valutazione del rischio per questo requisito (probabilità, impatto, misure in essere, gap…)."></textarea>
</div>
<div class="rm-field"><label for="mr-eval-note">Note</label>
<textarea id="mr-eval-note" maxlength="2000" placeholder="Note operative, riferimenti, evidenze…"></textarea>
</div>
<div class="rm-err" id="mr-eval-err" role="alert"></div>
</div>
<div class="rm-foot">
<button class="btn btn-outline" type="button" onclick="mrCloseEval()">Annulla</button>
<button class="btn btn-primary" type="button" id="mr-eval-save" onclick="mrSaveEval()">Salva</button>
</div>
</div>
</div>
<script src="/js/api.js?v=20260621"></script> <script src="/js/api.js?v=20260621"></script>
<script src="/js/common.js?v=20260621"></script> <script src="/js/common.js?v=20260621"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
@@ -117,11 +175,20 @@
* Dati da api.frameworkCatalog() (tabelle cfg_nis2_*). Codifica non modificabile. * Dati da api.frameworkCatalog() (tabelle cfg_nis2_*). Codifica non modificabile.
* Le righe si "spengono" per la classe del soggetto (importante/essenziale). * Le righe si "spengono" per la classe del soggetto (importante/essenziale).
*/ */
let MR = { measures: [], org_class: null }; let MR = { measures: [], org_class: null, has_org: false, stati: [], byId: {} };
function el(id) { return document.getElementById(id); } function el(id) { return document.getElementById(id); }
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; } function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
const STATO_META = {
da_valutare: { label: 'Da valutare', cls: 'sv-todo' },
non_applicabile: { label: 'Non applicabile', cls: 'sv-na' },
non_conforme: { label: 'Non conforme', cls: 'sv-ko' },
parziale: { label: 'Parziale', cls: 'sv-mid' },
conforme: { label: 'Conforme', cls: 'sv-ok' }
};
function statoMeta(s) { return STATO_META[s] || STATO_META.da_valutare; }
document.addEventListener('DOMContentLoaded', async function () { document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return; if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it'); if (window.I18n && I18n.init) I18n.init('it');
@@ -135,6 +202,11 @@
const data = await api.frameworkCatalog(); const data = await api.frameworkCatalog();
MR.measures = (data && data.measures) || []; MR.measures = (data && data.measures) || [];
MR.org_class = (data && data.org_class) || null; MR.org_class = (data && data.org_class) || null;
MR.has_org = !!(data && data.has_org);
MR.stati = (data && data.stati) || Object.keys(STATO_META);
// indicizza i requisiti per id (per la modale)
MR.byId = {};
MR.measures.forEach(function (m) { (m.requisiti || []).forEach(function (q) { MR.byId[q.id] = q; }); });
// se conosco la classe dell'org, parto in modalità "auto"; altrimenti mostro tutto // se conosco la classe dell'org, parto in modalità "auto"; altrimenti mostro tutto
if (!MR.org_class) el('mr-class').value = 'all'; if (!MR.org_class) el('mr-class').value = 'all';
el('mr-stat-mis').textContent = (data && data.totals && data.totals.misure) || MR.measures.length; el('mr-stat-mis').textContent = (data && data.totals && data.totals.misure) || MR.measures.length;
@@ -212,7 +284,8 @@
html += '</div>'; html += '</div>';
html += '<table class="mr-table"><thead><tr>' + html += '<table class="mr-table"><thead><tr>' +
'<th>Requisito</th><th>Descrizione</th><th>Procedura (default)</th><th>Rischio (default)</th>' + '<th>Requisito</th><th>Descrizione</th><th>Procedura (default)</th><th>Rischio (default)</th>' +
'<th style="text-align:center">Imp.</th><th style="text-align:center">Ess.</th></tr></thead><tbody>'; '<th style="text-align:center">Imp.</th><th style="text-align:center">Ess.</th>' +
(MR.has_org ? '<th>Stato / Valutazione</th>' : '') + '</tr></thead><tbody>';
g.rows.forEach(function (r) { g.rows.forEach(function (r) {
const req = r.req, m = r.m; const req = r.req, m = r.m;
html += '<tr class="mr-row' + (r.active ? '' : ' spento') + '">'; html += '<tr class="mr-row' + (r.active ? '' : ' spento') + '">';
@@ -226,12 +299,58 @@
html += '<td>' + (req.risk_code ? '<span class="mr-code-pill">' + esc(req.risk_code) + '</span> ' + esc(req.risk_descr || '') : '—') + '</td>'; html += '<td>' + (req.risk_code ? '<span class="mr-code-pill">' + esc(req.risk_code) + '</span> ' + esc(req.risk_descr || '') : '—') + '</td>';
html += '<td class="mr-yn ' + (req.applies_important ? 'y' : 'n') + '">' + (req.applies_important ? '✓' : '—') + '</td>'; html += '<td class="mr-yn ' + (req.applies_important ? 'y' : 'n') + '">' + (req.applies_important ? '✓' : '—') + '</td>';
html += '<td class="mr-yn ' + (req.applies_essential ? 'y' : 'n') + '">' + (req.applies_essential ? '✓' : '—') + '</td>'; html += '<td class="mr-yn ' + (req.applies_essential ? 'y' : 'n') + '">' + (req.applies_essential ? '✓' : '—') + '</td>';
if (MR.has_org) {
const sm = statoMeta(req.stato);
const val = req.valutazione_rischio ? esc(req.valutazione_rischio).slice(0, 90) + (req.valutazione_rischio.length > 90 ? '…' : '') : '';
html += '<td><button type="button" class="sv-badge ' + sm.cls + '" onclick="mrEdit(' + req.id + ')" title="Modifica valutazione">' + esc(sm.label) + '</button>' +
(val ? '<div class="sv-val">' + val + '</div>' : '') + '</td>';
}
html += '</tr>'; html += '</tr>';
}); });
html += '</tbody></table></div>'; html += '</tbody></table></div>';
}); });
el('mr-list').innerHTML = html; el('mr-list').innerHTML = html;
} }
// ── Valutazione per requisito (org-scoped) ──────────────────────────
function mrEdit(reqId) {
const q = MR.byId[reqId];
if (!q) return;
el('mr-eval-id').value = reqId;
el('mr-modal-sub').textContent = (q.requisito_code || '') + ' · ' + (q.proc_code || '') + ' / ' + (q.risk_code || '');
let ctx = '<div>' + esc(q.requisito_descr || '') + '</div>';
if (q.risk_code) ctx += '<div style="margin-top:6px"><span class="pill">' + esc(q.risk_code) + '</span> ' + esc(q.risk_descr || '') + '</div>';
el('mr-eval-ctx').innerHTML = ctx;
el('mr-eval-stato').value = q.stato || 'da_valutare';
el('mr-eval-val').value = q.valutazione_rischio || '';
el('mr-eval-note').value = q.note || '';
el('mr-eval-err').textContent = '';
el('mr-modal').classList.add('open');
}
function mrCloseEval() { el('mr-modal').classList.remove('open'); }
async function mrSaveEval() {
const reqId = parseInt(el('mr-eval-id').value, 10);
const btn = el('mr-eval-save');
el('mr-eval-err').textContent = '';
btn.disabled = true;
try {
const res = await api.frameworkSetState({
requisito_id: reqId,
stato: el('mr-eval-stato').value,
valutazione_rischio: el('mr-eval-val').value.trim(),
note: el('mr-eval-note').value.trim()
});
const q = MR.byId[reqId];
if (q && res) { q.stato = res.stato; q.valutazione_rischio = res.valutazione_rischio; q.note = res.note; }
mrCloseEval();
mrRender();
const hint = el('mr-hint'); if (hint) { hint.textContent = 'Valutazione salvata'; setTimeout(function () { hint.textContent = ''; }, 2500); }
} catch (e) {
el('mr-eval-err').textContent = (e && e.message) ? e.message : 'Errore nel salvataggio.';
} finally { btn.disabled = false; }
}
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') mrCloseEval(); });
(function () { const ov = el('mr-modal'); if (ov) ov.addEventListener('click', function (e) { if (e.target === ov) mrCloseEval(); }); })();
</script> </script>
</body> </body>
</html> </html>