From cb238e8380bc2a86d08872507dea2439bca0dacf Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Thu, 18 Jun 2026 19:25:16 +0200 Subject: [PATCH] =?UTF-8?q?[FEAT]=20#384=20p.2.2/3=20=E2=80=94=20colonne?= =?UTF-8?q?=20multi-valore=20+=20link=20attivi=20bidirezionali=20Policy<->?= =?UTF-8?q?Misure=20e=20Requisiti?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Misure e Requisiti: colonna Policy (default) mostra tutte le policy collegate al requisito come codici Policy.NN cliccabili -> aprono la policy (policies.html?policy=ID). Policy: colonna Requisito NIS2 mostra tutti i requisiti collegati (codifica MISURA+ REQUISITO, es. 'DE.CM-01 comma 1.') come link -> evidenziano il requisito (misure-requisiti.html?requisito=ID). Backend: FrameworkController::catalog espone policies[] per requisito e PolicyController::list espone requisiti[] per policy (org-scoped, tabella requisito_policy mig.059, try/catch con degrado al dato di default). Solo lettura/visualizzazione: nessuna modifica dati, nessuna migrazione. Co-Authored-By: Claude Opus 4.8 --- .../controllers/FrameworkController.php | 27 +++++++++++++ application/controllers/PolicyController.php | 29 ++++++++++++++ public/misure-requisiti.html | 40 ++++++++++++++++++- public/policies.html | 21 +++++++++- public/version.json | 2 +- 5 files changed, 114 insertions(+), 5 deletions(-) diff --git a/application/controllers/FrameworkController.php b/application/controllers/FrameworkController.php index 203588f..a502cf6 100644 --- a/application/controllers/FrameworkController.php +++ b/application/controllers/FrameworkController.php @@ -79,6 +79,32 @@ class FrameworkController extends BaseController } } + // Ticket #384 p.2.2/p.3 — policy d'organizzazione collegate a ciascun requisito + // (M:N org-scoped, tabella requisito_policy mig.059). try/catch: l'eventuale assenza + // della tabella NON deve rompere il catalogo (degrada al solo proc_code di default). + $policiesByReq = []; + if ($orgId) { + try { + foreach (Database::fetchAll( + 'SELECT rp.requisito_id, p.id AS policy_id, p.title, p.proc_code, p.status + FROM requisito_policy rp + JOIN policies p ON p.id = rp.policy_id + WHERE rp.organization_id = ? + ORDER BY (p.proc_code IS NULL), p.proc_code, p.title', + [$orgId] + ) as $rp) { + $policiesByReq[(int) $rp['requisito_id']][] = [ + 'id' => (int) $rp['policy_id'], + 'proc_code' => $rp['proc_code'], + 'title' => $rp['title'], + 'status' => $rp['status'], + ]; + } + } catch (\Throwable $e) { + error_log('[FrameworkController] requisito_policy lookup (org ' . $orgId . '): ' . $e->getMessage()); + } + } + $ambiti = Database::fetchAll('SELECT nist_code, nist_descr FROM cfg_nis2_ambiti ORDER BY nist_code ASC'); $misure = Database::fetchAll( @@ -108,6 +134,7 @@ class FrameworkController extends BaseController 'requisito_descr' => $q['requisito_descr'], 'proc_code' => $q['proc_code'], 'proc_descr' => $q['proc_descr'], + 'policies' => $policiesByReq[$rid] ?? [], // #384 p.2.2/p.3 (multi-valore) 'risk_code' => $q['risk_code'], 'risk_descr' => $q['risk_descr'], 'applies_important' => (int) $q['applies_important'], diff --git a/application/controllers/PolicyController.php b/application/controllers/PolicyController.php index f6a5c21..a7196fb 100644 --- a/application/controllers/PolicyController.php +++ b/application/controllers/PolicyController.php @@ -52,6 +52,35 @@ class PolicyController extends BaseController $params ); + // Ticket #384 p.2.2/p.3 — requisiti collegati a ciascuna policy (M:N org-scoped, + // tabella requisito_policy mig.059). Il codice mostrato e' la concatenazione + // MISURA + REQUISITO (spec 2.2). try/catch: degrada al solo nis2_article se assente. + try { + $reqRows = Database::fetchAll( + 'SELECT rp.policy_id, q.id AS requisito_id, q.misura_code, q.requisito_code + FROM requisito_policy rp + JOIN cfg_nis2_requisiti q ON q.id = rp.requisito_id + WHERE rp.organization_id = ? + ORDER BY q.misura_code, q.n, q.id', + [$this->getCurrentOrgId()] + ); + $reqByPolicy = []; + foreach ($reqRows as $r) { + $reqByPolicy[(int) $r['policy_id']][] = [ + 'id' => (int) $r['requisito_id'], + 'misura_code' => $r['misura_code'], + 'requisito_code' => $r['requisito_code'], + 'code' => trim(($r['misura_code'] ?? '') . ' ' . ($r['requisito_code'] ?? '')), + ]; + } + foreach ($policies as &$pol) { + $pol['requisiti'] = $reqByPolicy[(int) $pol['id']] ?? []; + } + unset($pol); + } catch (\Throwable $e) { + error_log('[PolicyController] requisiti per policy (org ' . $this->getCurrentOrgId() . '): ' . $e->getMessage()); + } + $this->jsonSuccess($policies); } diff --git a/public/misure-requisiti.html b/public/misure-requisiti.html index 0bfb140..b1fb4ee 100644 --- a/public/misure-requisiti.html +++ b/public/misure-requisiti.html @@ -31,6 +31,11 @@ .mr-table th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); background:#fff; position:sticky; top:0; } .mr-req-code { white-space:nowrap; font-weight:700; color:#475569; } .mr-code-pill { display:inline-block; font-size:.7rem; font-weight:700; color:#3730a3; background:#eef2ff; border-radius:6px; padding:1px 6px; } + /* Ticket #384 p.3 — codici Policy come link attivi verso il menu POLICY */ + a.mr-code-link { text-decoration:none; cursor:pointer; margin-right:3px; } + a.mr-code-link:hover { background:#e0e7ff; text-decoration:underline; } + a.mr-code-link:focus-visible { outline:2px solid #3730a3; outline-offset:1px; } + .mr-row-focus td { background:#fef9c3 !important; transition:background .4s ease; } .mr-yn { text-align:center; font-weight:800; } .mr-yn.y { color:#166534; } .mr-yn.n { color:#cbd5e1; } @@ -183,6 +188,24 @@ // Il codice canonico nel catalogo resta proc_code (chiave di join requisiti<->procedure): qui si rietichetta solo per la UI. function policyCode(code) { return code ? String(code).replace(/^Proc\./i, 'Policy.') : code; } + // Ticket #384 p.2.2/p.3 — colonna "Policy (default)" multi-valore con link attivi al menu POLICY. + // Usa le policy d'organizzazione collegate (req.policies dal backend); in assenza degrada + // al codice di default del catalogo (proc_code), com'era prima. + function renderPolicyLinks(req) { + const pols = req.policies || []; + if (pols.length) { + return pols.map(function (p) { + const code = p.proc_code ? policyCode(p.proc_code) : ('Policy.' + p.id); + const title = (p.title || '').replace(/"/g, '"'); + return '' + esc(code) + ''; + }).join(' '); + } + return req.proc_code + ? '' + esc(policyCode(req.proc_code)) + ' ' + esc(req.proc_descr || '') + : '—'; + } + const STATO_META = { da_valutare: { label: 'Da valutare', cls: 'sv-todo' }, non_applicabile: { label: 'Non applicabile', cls: 'sv-na' }, @@ -215,12 +238,25 @@ el('mr-stat-mis').textContent = (data && data.totals && data.totals.misure) || MR.measures.length; el('mr-stat-req').textContent = (data && data.totals && data.totals.requisiti) || 0; el('mr-stats').style.display = 'flex'; + // Ticket #384 p.3 — deep-link dal menu POLICY: ?requisito=ID → mostra ed evidenzia il requisito. + const focusReq = parseInt(new URLSearchParams(location.search).get('requisito') || '', 10); + if (focusReq) el('mr-class').value = 'all'; // evita che la riga sia filtrata via mrRender(); + if (focusReq) mrFocusRequisito(focusReq); } catch (e) { el('mr-list').innerHTML = '
Impossibile caricare l\'elenco. Riprova più tardi.
'; } } + // Ticket #384 p.3 — scorre alla riga del requisito e la evidenzia (arrivo da link Policy). + function mrFocusRequisito(reqId) { + const row = document.getElementById('req-' + reqId); + if (!row) return; + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + row.classList.add('mr-row-focus'); + setTimeout(function () { row.classList.remove('mr-row-focus'); }, 4000); + } + // classe effettiva selezionata (risolve "auto" sulla classe dell'org) function mrEffectiveClass() { const sel = el('mr-class').value; @@ -291,14 +327,14 @@ (MR.has_org ? 'Stato / Valutazione' : '') + ''; g.rows.forEach(function (r) { const req = r.req, m = r.m; - html += ''; + html += ''; if (view === 'area') { html += '' + esc(m.misura_code) + ' ' + esc(req.requisito_code) + ''; } else { html += '' + esc(req.requisito_code) + ''; } html += '' + esc(req.requisito_descr) + ''; - html += '' + (req.proc_code ? '' + esc(policyCode(req.proc_code)) + ' ' + esc(req.proc_descr || '') : '—') + ''; + html += '' + renderPolicyLinks(req) + ''; html += '' + (req.risk_code ? '' + esc(req.risk_code) + ' ' + esc(req.risk_descr || '') : '—') + ''; html += '' + (req.applies_important ? '✓' : '—') + ''; html += '' + (req.applies_essential ? '✓' : '—') + ''; diff --git a/public/policies.html b/public/policies.html index 46a7376..ed6dc3d 100644 --- a/public/policies.html +++ b/public/policies.html @@ -410,6 +410,19 @@ currentView = 'list'; } + // Ticket #384 p.2.2/p.3 — colonna "Requisito NIS2" multi-valore con link attivi a MISURE E REQUISITI. + // Il codice mostrato e' MISURA + REQUISITO (dal backend, tabella requisito_policy). In assenza di + // associazioni degrada al campo nis2_article, com'era prima. + function renderReqLinks(p) { + const reqs = (p && p.requisiti) || []; + if (reqs.length) { + return reqs.map(r => + `${escapeHtml(r.code || r.misura_code || '')}` + ).join(', '); + } + return escapeHtml(p.nis2_article || '-'); + } + // ── Load Policies ─────────────────────────────────────── async function loadPolicies() { const params = {}; @@ -475,7 +488,7 @@ ${escapeHtml(p.title)} ${escapeHtml(catLabel)} - ${escapeHtml(p.nis2_article || '-')} + ${renderReqLinks(p)} ${escapeHtml(p.version || '1.0')} ${escapeHtml(statusLabel)} ${formatDate(p.next_review_date)} @@ -1075,7 +1088,11 @@ } // ── Init ──────────────────────────────────────────────── - loadPolicies(); + loadPolicies().then(function () { + // Ticket #384 p.3 — deep-link da MISURE E REQUISITI: ?policy=ID apre il dettaglio della policy. + const pid = parseInt(new URLSearchParams(location.search).get('policy') || '', 10); + if (pid) viewPolicy(pid); + }); diff --git a/public/version.json b/public/version.json index 7e61cb0..547768f 100644 --- a/public/version.json +++ b/public/version.json @@ -1 +1 @@ -{"version": "1.23.6", "build": "2026-06-18-v1.23.6", "date": "2026-06-18", "changelog": "Ticket #384 punto 2.2/3 (fondazione dati): introdotto il modello relazionale REQUISITO<->POLICY (migrazione 059, additiva/reversibile). Ripristinata sulla riga policy la chiave 'proc_code' (persa quando le procedure di default venivano copiate nell'elenco Policy) e creata la tabella requisito_policy per il legame molti-a-molti org-scoped (un requisito puo' avere piu' policy e una policy piu' requisiti). Backfill sicuro: valorizzato proc_code su 42 policy e popolate 116 associazioni di default esistenti. Nessun impatto visibile per l'utente in questo passo: e' la base dati su cui poggeranno l'unificazione multi-valore delle colonne (2.2) e i link attivi bidirezionali tra i due menu (3). Restano in lavorazione a fasi: UI multi-valore + link bidirezionali (2.2/3) e matrice RACI assegnabile per policy con R e A obbligatorie in approvazione (punto 6)."} +{"version": "1.23.7", "build": "2026-06-18-v1.23.7", "date": "2026-06-18", "changelog": "Ticket #384 punti 2.2 e 3 (UI multi-valore + link attivi bidirezionali). In MISURE E REQUISITI la colonna 'Policy (default)' ora mostra TUTTE le policy collegate al requisito (non piu' una sola) come codici 'Policy.NN' cliccabili: il clic porta direttamente alla policy nel menu POLICY. Nel menu POLICY la colonna 'Requisito NIS2' ora mostra TUTTI i requisiti collegati con la codifica MISURA+REQUISITO (es. 'DE.CM-01 comma 1.') come link cliccabili: il clic porta al requisito in MISURE E REQUISITI, evidenziandolo. Navigazione nei due sensi (Policy <-> Misure e Requisiti). Nessuna modifica ai dati: poggia sul modello relazionale gia' introdotto (tabella requisito_policy, mig.059). Resta a fasi: matrice RACI assegnabile per policy con R e A obbligatorie in approvazione (punto 6)."}