Files
nis2-agile/public/misure-requisiti.html
T
DevEnv nis2-agileandClaude Opus 4.8 42529043d3 [FEAT] ARIA puntatore guida "Mostrami dov'è" — spotlight verso il menu (Fase D)
Porting del pattern TRPG (CHANGE_aria_voice_pointer.md) sulla sidebar V2:
- public/js/aria-guide.js (nuovo): DESTINATIONS (26 voci menu → keyword IT),
  matchDestination(question), pointTo(nav,label) = overlay + spotlight pulsante +
  puntatore animato + coach-mark "Vai →"; Esc/click per chiudere; fallback navigate
  se la voce non è visibile; rispetta prefers-reduced-motion; self-contained, no inline-CSS.
- common-bi.js: data-nav="<href senza .html>" sulle voci sidebar (aggancio puntatore).
- common.js: inietta aria-guide.js dopo login; dopo ogni risposta ARIA, se la domanda
  combacia con una destinazione mostra il bottone "📍 Mostrami dov'è: <voce>".
Frontend-only, additivo. Cache-buster common.js/common-bi.js -> 20260624f.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:39:47 +02:00

415 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Misure e Requisiti - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<style>
.mr-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:14px; font-size:.92rem; line-height:1.6; }
.mr-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
.mr-ro { font-size:.8rem; color:#3730a3; background:#eef2ff; border:1px solid #c7d2fe; border-radius:8px; padding:8px 12px; margin:0 0 16px; display:flex; gap:8px; align-items:center; }
.mr-stats { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:16px; }
.mr-stat { flex:1 1 140px; min-width:120px; background:#fff; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; padding:12px 14px; }
.mr-stat .n { font-size:1.5rem; font-weight:800; line-height:1; }
.mr-stat .l { font-size:.76rem; color:var(--gray-500,#6b7280); margin-top:4px; }
.mr-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.mr-toolbar label { font-weight:600; font-size:.82rem; }
.mr-toolbar select, .mr-toolbar input[type=search] { padding:8px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.88rem; }
.mr-toolbar .grow { flex:1 1 auto; }
.mr-group { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; margin-bottom:14px; overflow:hidden; }
.mr-group-head { background:#f8fafc; padding:11px 14px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
.mr-group-head .code { font-weight:800; color:#1e3a8a; font-size:.95rem; }
.mr-group-head .area { font-size:.78rem; color:var(--gray-500,#6b7280); }
.mr-group-head .descr { font-size:.86rem; color:#334155; margin-top:3px; line-height:1.5; }
.mr-tag { display:inline-block; font-size:.66rem; font-weight:800; padding:2px 8px; border-radius:20px; margin-left:6px; vertical-align:middle; }
.mr-tag.ess { background:#fef3c7; color:#92400e; }
.mr-table { width:100%; border-collapse:collapse; font-size:.85rem; }
.mr-table th, .mr-table td { text-align:left; padding:9px 11px; border-bottom:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
.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; }
.mr-row.spento { opacity:.4; }
.mr-row.spento td { font-style:italic; }
.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>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="mr.title">Misure e Requisiti</h2>
<div class="content-header-actions">
<span class="savehint" id="mr-hint" role="status" aria-live="polite"></span>
</div>
</header>
<div class="content-body">
<div class="mr-intro">
<strong>L'elenco canonico delle misure di sicurezza di base e dei relativi requisiti.</strong>
Ogni requisito riporta la <strong>policy di default</strong> che lo soddisfa e il <strong>rischio di default</strong> associato. È la base su cui si costruiscono policy, rischi e attività: le righe si "spengono" quando non sono applicabili alla classe del tuo soggetto.
</div>
<div class="mr-ro">
🔒 <span>Elenco di riferimento, <strong>sola lettura</strong>: la codifica (misure, requisiti, ordine) non è modificabile. Policy e rischi qui elencati vengono proposti come <em>default</em> nei rispettivi moduli, dove potrai personalizzarli.</span>
</div>
<div class="mr-note">
Fonte: <strong>Determinazione ACN n. 164179 del 14 aprile 2025</strong> (misure di base, Allegati 1–2). Le misure applicabili dipendono dalla classe del soggetto: <strong>importanti</strong> 37 misure / 92 requisiti, <strong>essenziali</strong> 43 / 116. Strumento di supporto, non un parere legale.
</div>
<div class="mr-stats" id="mr-stats" style="display:none;">
<div class="mr-stat"><div class="n" id="mr-stat-mis">0</div><div class="l">Misure</div></div>
<div class="mr-stat"><div class="n" id="mr-stat-req">0</div><div class="l">Requisiti</div></div>
<div class="mr-stat"><div class="n" id="mr-stat-active">0</div><div class="l">Applicabili al soggetto</div></div>
</div>
<div class="mr-toolbar">
<label for="mr-view">Vista:</label>
<select id="mr-view" onchange="mrRender()">
<option value="misura">Per misura</option>
<option value="area">Per area politica</option>
</select>
<label for="mr-class">Soggetto:</label>
<select id="mr-class" onchange="mrRender()">
<option value="auto">Automatico (dalla mia org)</option>
<option value="important">Importante</option>
<option value="essential">Essenziale</option>
<option value="all">Tutti (mostra tutto)</option>
</select>
<label style="font-weight:500;"><input type="checkbox" id="mr-hide" onchange="mrRender()"> nascondi non applicabili</label>
<span class="grow"></span>
<input type="search" id="mr-q" placeholder="Cerca codice o testo…" oninput="mrRender()" style="min-width:200px;">
</div>
<div id="mr-list" aria-live="polite"><div class="mr-empty">Caricamento…</div></div>
</div>
</main>
</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=20260631"></script>
<script src="/js/common.js?v=20260624f"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260624f"></script>
<script src="/js/i18n.js?v=20260631"></script>
<script src="/js/help.js?v=20260624e"></script>
<script>
'use strict';
/*
* NIS2 Agile - Elenco canonico Misure/Requisiti (Epic C / C1) - SOLA LETTURA.
* Dati da api.frameworkCatalog() (tabelle cfg_nis2_*). Codifica non modificabile.
* Le righe si "spengono" per la classe del soggetto (importante/essenziale).
*/
let MR = { measures: [], org_class: null, has_org: false, stati: [], byId: {} };
function el(id) { return document.getElementById(id); }
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
// Ticket #384 p.2.1 — i codici procedura/policy si presentano con prefisso "Policy." (Proc.NN -> Policy.NN).
// 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, '&quot;');
return '<a class="mr-code-pill mr-code-link" href="policies.html?policy=' + p.id +
'" title="' + esc(title) + '">' + esc(code) + '</a>';
}).join(' ');
}
return req.proc_code
? '<span class="mr-code-pill">' + esc(policyCode(req.proc_code)) + '</span> ' + esc(req.proc_descr || '')
: '—';
}
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 () {
if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar();
if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
await mrLoad();
});
async function mrLoad() {
try {
const data = await api.frameworkCatalog();
MR.measures = (data && data.measures) || [];
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
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-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 = '<div class="mr-empty">Impossibile caricare l\'elenco. Riprova più tardi.</div>';
}
}
// 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;
if (sel === 'auto') return MR.org_class; // può essere null => tutto attivo
if (sel === 'all') return null;
return sel; // important | essential
}
// un requisito è "attivo" per la classe effettiva?
function reqActive(req, cls) {
if (!cls) return true;
return cls === 'important' ? !!req.applies_important : !!req.applies_essential;
}
function matchQuery(m, req, q) {
if (!q) return true;
q = q.toLowerCase();
return (m.misura_code + ' ' + m.misura_descr + ' ' + m.area_politica + ' ' +
req.requisito_code + ' ' + req.requisito_descr + ' ' +
(req.proc_descr || '') + ' ' + (req.risk_descr || '')).toLowerCase().indexOf(q) >= 0;
}
function mrRender() {
const cls = mrEffectiveClass();
const hide = el('mr-hide').checked;
const q = (el('mr-q').value || '').trim();
const view = el('mr-view').value;
// costruisco le righe filtrate (misura -> requisiti visibili)
let activeCount = 0;
const groupsByKey = {}; // key -> {label, descr, area, code, essOnly, rows:[]}
MR.measures.forEach(function (m) {
const essOnly = !m.applies_important;
m.requisiti.forEach(function (req) {
if (!matchQuery(m, req, q)) return;
const active = reqActive(req, cls);
if (active) activeCount++;
if (hide && !active) return;
let key, head;
if (view === 'area') {
key = m.area_politica || '—';
head = { code: m.area_politica || '—', descr: '', area: '', essOnly: false };
} else {
key = m.misura_code;
head = { code: m.misura_code + ' — ' + (m.nist_code || ''), descr: m.misura_descr, area: m.area_politica, essOnly: essOnly };
}
if (!groupsByKey[key]) groupsByKey[key] = Object.assign({ rows: [] }, head);
groupsByKey[key].rows.push({ m: m, req: req, active: active });
});
});
el('mr-stat-active').textContent = activeCount;
const keys = Object.keys(groupsByKey);
if (!keys.length) { el('mr-list').innerHTML = '<div class="mr-empty">Nessun risultato per i filtri selezionati.</div>'; return; }
let html = '';
keys.forEach(function (key) {
const g = groupsByKey[key];
html += '<div class="mr-group">';
html += '<div class="mr-group-head"><div><span class="code">' + esc(g.code) + '</span>' +
(g.essOnly ? '<span class="mr-tag ess">solo essenziali</span>' : '') + '</div>';
if (g.area) html += '<div class="area">' + esc(g.area) + '</div>';
if (g.descr) html += '<div class="descr">' + esc(g.descr) + '</div>';
html += '</div>';
html += '<table class="mr-table"><thead><tr>' +
'<th>Requisito</th><th>Descrizione</th><th>Policy (default)</th><th>Rischio (default)</th>' +
'<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) {
const req = r.req, m = r.m;
html += '<tr id="req-' + req.id + '" class="mr-row' + (r.active ? '' : ' spento') + '">';
if (view === 'area') {
html += '<td class="mr-req-code"><span class="mr-code-pill">' + esc(m.misura_code) + '</span> ' + esc(req.requisito_code) + '</td>';
} else {
html += '<td class="mr-req-code">' + esc(req.requisito_code) + '</td>';
}
html += '<td>' + esc(req.requisito_descr) + '</td>';
html += '<td>' + renderPolicyLinks(req) + '</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_essential ? 'y' : 'n') + '">' + (req.applies_essential ? '✓' : '—') + '</td>';
if (MR.has_org) {
const sm = statoMeta(req.stato);
const _vr = req.valutazione_rischio || '';
const val = _vr ? esc(_vr.slice(0, 90)) + (_vr.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 += '</tbody></table></div>';
});
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 || '') + ' · ' + (policyCode(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 = '';
MR._lastFocus = document.activeElement;
el('mr-modal').classList.add('open');
setTimeout(function () { const s = el('mr-eval-stato'); if (s) s.focus(); }, 0);
}
function mrCloseEval() {
el('mr-modal').classList.remove('open');
if (MR._lastFocus && typeof MR._lastFocus.focus === 'function') { try { MR._lastFocus.focus(); } catch (e) {} }
}
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) return;
ov.addEventListener('click', function (e) { if (e.target === ov) mrCloseEval(); });
// focus-trap: Tab/Shift+Tab restano dentro la modale aperta
ov.addEventListener('keydown', function (e) {
if (e.key !== 'Tab' || !ov.classList.contains('open')) return;
const nodes = ov.querySelectorAll('select, textarea, button');
const list = Array.prototype.filter.call(nodes, function (x) { return !x.disabled && x.offsetParent !== null; });
if (!list.length) return;
const first = list[0], last = list[list.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
})();
</script>
</body>
</html>