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 = '