Click su un'intestazione (Nome/Tipo/Categoria/Criticita/Rilevanza NIS2/Owner/Stato) ordina l'inventario; secondo click inverte (freccia ▲/▼, aria-sort). Criticita e Rilevanza ordinano per valore reale, non alfabetico. Client-side sull'inventario gia' caricato (no paginazione su assets), si combina coi filtri, accessibile da tastiera. Help aggiornato + cache-buster help.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
349 lines
20 KiB
HTML
349 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="it">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<title>Scadenziario - 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>
|
|
.rev-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:16px; font-size:.92rem; line-height:1.6; }
|
|
.rev-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 18px; }
|
|
.rev-stats { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:18px; }
|
|
.rev-stat { flex:1 1 150px; min-width:130px; background:#fff; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; padding:12px 14px; }
|
|
.rev-stat .n { font-size:1.5rem; font-weight:800; line-height:1; }
|
|
.rev-stat .l { font-size:.78rem; color:var(--gray-500,#6b7280); margin-top:4px; }
|
|
.rev-stat.s-overdue .n { color:#991b1b; }
|
|
.rev-stat.s-due .n { color:#b45309; }
|
|
.rev-stat.s-ok .n { color:#166534; }
|
|
.rev-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
|
|
.rev-toolbar select { padding:8px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.88rem; }
|
|
.rev-toolbar .grow { flex:1 1 auto; }
|
|
.rev-table { width:100%; border-collapse:collapse; font-size:.88rem; }
|
|
.rev-table th, .rev-table td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
|
|
.rev-table th { font-size:.74rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
|
|
.rev-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; }
|
|
.rev-badge.type { background:#f1f5f9; color:#475569; }
|
|
.gap-pill { display:inline-block; font-size:.72rem; font-weight:800; padding:2px 9px; border-radius:20px; }
|
|
.gap-pill.ok { background:#dcfce7; color:#166534; }
|
|
.gap-pill.due { background:#fef3c7; color:#92400e; }
|
|
.gap-pill.bad { background:#fee2e2; color:#991b1b; }
|
|
.rev-actions-cell { display:flex; gap:6px; flex-wrap:wrap; }
|
|
.rev-actions-cell .btn, .rev-toolbar .btn { padding:4px 9px; font-size:.76rem; }
|
|
.rev-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); }
|
|
/* Modale */
|
|
.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:560px; box-shadow:0 20px 50px rgba(0,0,0,.25); }
|
|
.rm-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
|
|
.rm-head h3 { margin:0; font-size:1.05rem; }
|
|
.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 input[type=text], .rm-field input[type=number], .rm-field input[type=date], .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:64px; resize:vertical; }
|
|
.rm-row { display:flex; gap:12px; }
|
|
.rm-row > .rm-field { flex:1 1 0; }
|
|
.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="rev.title">Scadenziario</h2>
|
|
<div class="content-header-actions">
|
|
<span class="savehint" id="rev-hint" role="status" aria-live="polite"></span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="content-body">
|
|
<div class="rev-intro">
|
|
<strong>Revisioni e scadenze in un unico posto.</strong>
|
|
Tieni traccia delle revisioni periodiche di ruoli, competenze, inventario, procedure, rischi, fornitori e misure. Ogni voce mostra quando è prevista la prossima revisione e ti avvisa quando è in scadenza o scaduta.
|
|
</div>
|
|
<div class="rev-note">
|
|
Ancoraggio: <strong>GV.PO-02</strong> (riesame periodico delle policy), <strong>GV.SC-07</strong> (revisione dei fornitori), <strong>PR.AT</strong>, <strong>DE.CM</strong> (monitoraggio continuo). Gli obblighi di gestione del rischio fanno capo all'<strong>art. 24 D.Lgs. 138/2024</strong>. La periodicità puntuale è una buona prassi dove non fissata da norma o contratto. Strumento di supporto, non un parere legale.
|
|
</div>
|
|
|
|
<div class="rev-stats" id="rev-stats" style="display:none;">
|
|
<div class="rev-stat"><div class="n" id="rev-stat-total">0</div><div class="l">Voci totali</div></div>
|
|
<div class="rev-stat s-overdue"><div class="n" id="rev-stat-overdue">0</div><div class="l">Scadute</div></div>
|
|
<div class="rev-stat s-due"><div class="n" id="rev-stat-due">0</div><div class="l">In scadenza (30 gg)</div></div>
|
|
<div class="rev-stat s-ok"><div class="n" id="rev-stat-ok">0</div><div class="l">In regola</div></div>
|
|
</div>
|
|
|
|
<div class="rev-toolbar">
|
|
<button class="btn btn-primary btn-sm" onclick="revOpen()">+ Nuova scadenza</button>
|
|
<button class="btn btn-outline btn-sm" id="rev-sync-btn" onclick="revDoSync()">Sincronizza scadenze esistenti</button>
|
|
<span class="grow"></span>
|
|
<label for="rev-filter" style="font-weight:600;font-size:.82rem;">Stato:</label>
|
|
<select id="rev-filter" onchange="revRender()">
|
|
<option value="all">Tutti</option>
|
|
<option value="overdue">Scadute</option>
|
|
<option value="due">In scadenza</option>
|
|
<option value="ok">In regola</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div id="rev-list" aria-live="polite"></div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Modale scadenza (crea/modifica) -->
|
|
<div class="rm-overlay" id="rev-modal" role="dialog" aria-modal="true" aria-labelledby="rev-modal-title">
|
|
<div class="rm-dialog">
|
|
<div class="rm-head"><h3 id="rev-modal-title">Nuova scadenza</h3><button class="rm-close" type="button" onclick="revClose()" aria-label="Chiudi">×</button></div>
|
|
<div class="rm-body">
|
|
<input type="hidden" id="rev-id">
|
|
<div class="rm-field"><label for="rev-f-title">Titolo *</label><input type="text" id="rev-f-title" maxlength="255" placeholder="es. Riesame annuale della policy di backup"></div>
|
|
<div class="rm-row">
|
|
<div class="rm-field"><label for="rev-f-type">Tipo entità</label>
|
|
<select id="rev-f-type">
|
|
<option value="custom">Personalizzata</option>
|
|
<option value="role">Ruolo</option>
|
|
<option value="skill">Competenza</option>
|
|
<option value="inventory">Inventario</option>
|
|
<option value="procedure">Procedura</option>
|
|
<option value="risk">Rischio</option>
|
|
<option value="supplier">Fornitore</option>
|
|
<option value="measure">Misura</option>
|
|
</select>
|
|
</div>
|
|
<div class="rm-field"><label for="rev-f-owner">Owner (ruolo)</label><select id="rev-f-owner"><option value="">— nessuno —</option></select></div>
|
|
</div>
|
|
<div class="rm-row">
|
|
<div class="rm-field"><label for="rev-f-next">Prossima revisione *</label><input type="date" id="rev-f-next"></div>
|
|
<div class="rm-field"><label for="rev-f-freq">Frequenza (mesi)</label><input type="number" id="rev-f-freq" min="1" max="120" placeholder="es. 12"></div>
|
|
</div>
|
|
<div class="rm-field"><label for="rev-f-notes">Note</label><textarea id="rev-f-notes" maxlength="2000"></textarea></div>
|
|
<div class="rm-err" id="rev-err" role="alert"></div>
|
|
</div>
|
|
<div class="rm-foot"><button class="btn btn-outline" type="button" onclick="revClose()">Annulla</button><button class="btn btn-primary" type="button" id="rev-save" onclick="revSave()">Salva</button></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/js/api.js?v=20260631"></script>
|
|
<script src="/js/common.js?v=20260624g"></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=20260625c"></script>
|
|
<script>
|
|
'use strict';
|
|
/*
|
|
* NIS2 Agile - Scadenziario centralizzato (A4 Fase 4.4)
|
|
* review_schedule: revisioni periodiche di ruoli/competenze/inventario/procedure/rischi/fornitori/misure.
|
|
* Client api.rev* -> _acn (ritorna data, lancia su success=false).
|
|
* Ancoraggio GV.PO-02, GV.SC-07, PR.AT, DE.CM. Obblighi: art. 24 D.Lgs. 138/2024.
|
|
*/
|
|
let REV = { items: [], roles: [] };
|
|
|
|
function el(id) { return document.getElementById(id); }
|
|
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
|
|
|
const TYPE_LABELS = {
|
|
custom: 'Personalizzata', role: 'Ruolo', skill: 'Competenza', inventory: 'Inventario',
|
|
procedure: 'Procedura', risk: 'Rischio', supplier: 'Fornitore', measure: 'Misura'
|
|
};
|
|
const STATUS_META = {
|
|
ok: { cls: 'ok', label: 'In regola' },
|
|
due: { cls: 'due', label: 'In scadenza' },
|
|
overdue: { cls: 'bad', label: 'Scaduta' }
|
|
};
|
|
function typeLabel(t) { return TYPE_LABELS[t] || (t || '—'); }
|
|
function fmtDate(d) {
|
|
if (!d) return '—';
|
|
try { return new Date(d + 'T00:00:00').toLocaleDateString('it-IT'); } catch (e) { return esc(d); }
|
|
}
|
|
|
|
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();
|
|
|
|
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') revClose(); });
|
|
const ov = el('rev-modal');
|
|
if (ov) ov.addEventListener('click', function (e) { if (e.target === ov) revClose(); });
|
|
|
|
await revLoadShared();
|
|
await revLoad();
|
|
});
|
|
|
|
async function revLoadShared() {
|
|
try {
|
|
const rolesData = await api.orgRolesList();
|
|
REV.roles = (rolesData && rolesData.roles) || [];
|
|
revFillOwnerSelect();
|
|
} catch (e) {
|
|
// l'organigramma può non essere ancora popolato: non bloccare la pagina
|
|
REV.roles = [];
|
|
}
|
|
}
|
|
|
|
function revFillOwnerSelect() {
|
|
const sel = el('rev-f-owner');
|
|
let h = '<option value="">— nessuno —</option>';
|
|
REV.roles.forEach(function (r) {
|
|
h += '<option value="' + r.id + '">' + esc(r.role_name || r.name || ('Ruolo #' + r.id)) + '</option>';
|
|
});
|
|
sel.innerHTML = h;
|
|
}
|
|
|
|
async function revLoad() {
|
|
el('rev-list').innerHTML = '<div class="rev-empty">Caricamento…</div>';
|
|
try {
|
|
const data = await api.revList();
|
|
REV.items = (data && data.items) || [];
|
|
revRenderStats(data);
|
|
revRender();
|
|
} catch (e) {
|
|
el('rev-list').innerHTML = '<div class="rev-empty">Errore nel caricamento: ' + esc(e.message || e) + '</div>';
|
|
}
|
|
}
|
|
|
|
function revRenderStats(data) {
|
|
const total = (data && data.total != null) ? data.total : REV.items.length;
|
|
const overdue = (data && data.overdue != null) ? data.overdue : REV.items.filter(i => i.status === 'overdue').length;
|
|
const due = (data && data.due != null) ? data.due : REV.items.filter(i => i.status === 'due').length;
|
|
const ok = (data && data.ok != null) ? data.ok : REV.items.filter(i => i.status === 'ok').length;
|
|
el('rev-stat-total').textContent = total;
|
|
el('rev-stat-overdue').textContent = overdue;
|
|
el('rev-stat-due').textContent = due;
|
|
el('rev-stat-ok').textContent = ok;
|
|
el('rev-stats').style.display = total ? 'flex' : 'none';
|
|
}
|
|
|
|
function revRender() {
|
|
const filter = el('rev-filter').value;
|
|
const rows = REV.items.filter(i => filter === 'all' || i.status === filter);
|
|
if (!REV.items.length) {
|
|
el('rev-list').innerHTML = '<div class="rev-empty">Nessuna scadenza registrata. Aggiungine una con "+ Nuova scadenza" oppure importa quelle esistenti con "Sincronizza scadenze esistenti".</div>';
|
|
return;
|
|
}
|
|
if (!rows.length) {
|
|
el('rev-list').innerHTML = '<div class="rev-empty">Nessuna voce per il filtro selezionato.</div>';
|
|
return;
|
|
}
|
|
const body = rows.map(function (i) {
|
|
const meta = STATUS_META[i.status] || STATUS_META.ok;
|
|
const owner = i.owner_role_name ? esc(i.owner_role_name) : '—';
|
|
const freq = (i.frequency_months != null && i.frequency_months !== '') ? (esc(i.frequency_months) + ' mesi') : '';
|
|
return '<tr>'
|
|
+ '<td><strong>' + esc(i.title) + '</strong>'
|
|
+ (i.notes ? '<div class="gap-role-sub" style="font-size:.8rem;color:var(--gray-500,#6b7280);margin-top:3px;">' + esc(i.notes) + '</div>' : '') + '</td>'
|
|
+ '<td><span class="rev-badge type">' + esc(typeLabel(i.entity_type)) + '</span></td>'
|
|
+ '<td>' + owner + '</td>'
|
|
+ '<td>' + fmtDate(i.last_reviewed_at) + '</td>'
|
|
+ '<td>' + fmtDate(i.next_review_date) + (freq ? '<div style="font-size:.78rem;color:var(--gray-500,#6b7280);">ogni ' + freq + '</div>' : '') + '</td>'
|
|
+ '<td><span class="gap-pill ' + meta.cls + '">' + meta.label + '</span></td>'
|
|
+ '<td><div class="rev-actions-cell">'
|
|
+ '<button class="btn btn-outline" onclick="revComplete(' + i.id + ')">Segna revisionato</button>'
|
|
+ '<button class="btn btn-outline" onclick="revOpen(' + i.id + ')">Modifica</button>'
|
|
+ '<button class="btn btn-outline" onclick="revDelete(' + i.id + ')">Elimina</button>'
|
|
+ '</div></td>'
|
|
+ '</tr>';
|
|
}).join('');
|
|
el('rev-list').innerHTML = '<table class="rev-table"><thead><tr>'
|
|
+ '<th>Titolo</th><th>Tipo</th><th>Owner</th><th>Ultima revisione</th><th>Prossima revisione</th><th>Stato</th><th>Azioni</th>'
|
|
+ '</tr></thead><tbody>' + body + '</tbody></table>';
|
|
}
|
|
|
|
function revOpen(id) {
|
|
const i = id ? REV.items.find(x => x.id === id) : null;
|
|
el('rev-modal-title').textContent = i ? 'Modifica scadenza' : 'Nuova scadenza';
|
|
el('rev-id').value = i ? i.id : '';
|
|
el('rev-f-title').value = i ? (i.title || '') : '';
|
|
el('rev-f-type').value = i ? (i.entity_type || 'custom') : 'custom';
|
|
el('rev-f-owner').value = i && i.owner_role_id != null ? String(i.owner_role_id) : '';
|
|
el('rev-f-next').value = i ? (i.next_review_date || '') : '';
|
|
el('rev-f-freq').value = i && i.frequency_months != null ? i.frequency_months : '';
|
|
el('rev-f-notes').value = i ? (i.notes || '') : '';
|
|
el('rev-err').textContent = '';
|
|
el('rev-modal').classList.add('open');
|
|
setTimeout(() => el('rev-f-title').focus(), 50);
|
|
}
|
|
|
|
function revClose() { el('rev-modal').classList.remove('open'); }
|
|
|
|
async function revSave() {
|
|
const id = el('rev-id').value;
|
|
const title = el('rev-f-title').value.trim();
|
|
const next = el('rev-f-next').value;
|
|
if (!title) { el('rev-err').textContent = 'Il titolo è obbligatorio.'; return; }
|
|
if (!next) { el('rev-err').textContent = 'La data di prossima revisione è obbligatoria.'; return; }
|
|
const freqRaw = el('rev-f-freq').value.trim();
|
|
const ownerRaw = el('rev-f-owner').value;
|
|
const payload = {
|
|
title: title,
|
|
entity_type: el('rev-f-type').value,
|
|
owner_role_id: ownerRaw ? parseInt(ownerRaw, 10) : null,
|
|
frequency_months: freqRaw ? parseInt(freqRaw, 10) : null,
|
|
next_review_date: next,
|
|
notes: el('rev-f-notes').value.trim()
|
|
};
|
|
const btn = el('rev-save'); btn.disabled = true;
|
|
try {
|
|
if (id) { await api.revUpdate(parseInt(id, 10), payload); showNotification('Scadenza aggiornata.', 'success'); }
|
|
else { await api.revCreate(payload); showNotification('Scadenza creata.', 'success'); }
|
|
revClose();
|
|
await revLoad();
|
|
} catch (e) {
|
|
el('rev-err').textContent = e.message || 'Errore nel salvataggio.';
|
|
} finally { btn.disabled = false; }
|
|
}
|
|
|
|
async function revComplete(id) {
|
|
try {
|
|
await api.revComplete(id);
|
|
showNotification('Revisione registrata.', 'success');
|
|
await revLoad();
|
|
} catch (e) { showNotification('Errore: ' + (e.message || e), 'error'); }
|
|
}
|
|
|
|
async function revDelete(id) {
|
|
const i = REV.items.find(x => x.id === id);
|
|
if (!confirm('Eliminare la scadenza "' + (i ? i.title : '') + '"?')) return;
|
|
try {
|
|
await api.revDelete(id);
|
|
showNotification('Scadenza eliminata.', 'success');
|
|
await revLoad();
|
|
} catch (e) { showNotification('Errore: ' + (e.message || e), 'error'); }
|
|
}
|
|
|
|
async function revDoSync() {
|
|
const btn = el('rev-sync-btn'); btn.disabled = true;
|
|
try {
|
|
const r = await api.revSync();
|
|
const created = (r && r.created != null) ? r.created : 0;
|
|
const updated = (r && r.updated != null) ? r.updated : 0;
|
|
const skipped = (r && r.skipped != null) ? r.skipped : 0;
|
|
showNotification('Sincronizzazione completata: ' + created + ' aggiunte, ' + updated + ' aggiornate, ' + skipped + ' già presenti.', 'success');
|
|
await revLoad();
|
|
} catch (e) {
|
|
showNotification('Errore nella sincronizzazione: ' + (e.message || e), 'error');
|
|
} finally { btn.disabled = false; }
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|