Riscontro Simon sulla tabella Gestione Rischi: - Titoli di gruppo sui tripletti Probabilità/Impatto/Valutazione: da "VALUTAZIONE RISCHIO INERENTE/RESIDUO" a "Rischio Inerente"/"Rischio Residuo" (risks.html thead + i18n.js grp_inherent/grp_residual IT+EN). - Ritirata eccezione #3: banda Alert riportata a spec Simon 15-25/9-12/3-8/1-2. Simon ha ragione: con P,I in 1-5 i prodotti 7/11/13/14 non esistono → nessun buco. alertBand resta >=9 (=9-12 in pratica); aggiornati commento, help.js, manuale, KB (9-14 -> 9-12). - Cache-buster help.js/i18n.js -> 20260730b (i18n va bustato o riscrive il titolo). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
614 lines
42 KiB
HTML
614 lines
42 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>Attività stakeholder · NIS2 Agile</title>
|
|
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg CDN / NO Bootstrap Italia). -->
|
|
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
|
|
<link rel="stylesheet" href="/css/v3.css?v=20260627">
|
|
<style>
|
|
/* ── Mappa variabili legacy (style.css rimosso) → token V3 + widget base ── */
|
|
:root {
|
|
--primary:#1F5FA8; --gray-50:#F7FAFD; --gray-100:#EEF2F7; --gray-200:#E3E9F0;
|
|
--gray-400:#94A3B8; --gray-500:#64748B;
|
|
}
|
|
.btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; border:1px solid var(--line); background:#fff; color:var(--ink); border-radius:8px; padding:8px 13px; font:inherit; font-weight:600; font-size:12.5px; cursor:pointer; text-decoration:none; }
|
|
.btn:hover { background:#F5F8FC; border-color:#c7d4e4; }
|
|
.btn-outline { background:#fff; color:var(--ink); border-color:var(--line); }
|
|
.btn-outline:hover { background:#F5F8FC; border-color:#c7d4e4; }
|
|
.btn-sm { padding:5px 10px; font-size:11.5px; }
|
|
.sa-intro { background:#EAF1FA; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
|
|
.sa-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
|
.sa-tabs { display:flex; gap:6px; border-bottom:2px solid var(--gray-200,#e5e7eb); margin-bottom:16px; }
|
|
.sa-tab { padding:9px 16px; font-weight:600; font-size:.9rem; cursor:pointer; border:none; background:none; color:var(--gray-500,#6b7280); border-bottom:2px solid transparent; margin-bottom:-2px; }
|
|
.sa-tab.active { color:var(--primary,#2563eb); border-bottom-color:var(--primary,#2563eb); }
|
|
.sa-toolbar { display:flex; justify-content:flex-end; margin-bottom:12px; }
|
|
.sa-table { width:100%; border-collapse:collapse; font-size:.88rem; }
|
|
.sa-table th, .sa-table td { text-align:left; padding:9px 12px; border-top:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
|
|
.sa-table th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
|
|
.sa-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; white-space:nowrap; }
|
|
.sa-badge.k-questionnaire { background:#dbeafe; color:#1e40af; }
|
|
.sa-badge.k-read_ack { background:#dcfce7; color:#166534; }
|
|
.sa-badge.k-action { background:#ffedd5; color:#9a3412; }
|
|
.sa-badge.s-draft { background:#f3f4f6; color:#6b7280; }
|
|
.sa-badge.s-scheduled { background:#e0f2fe; color:#075985; }
|
|
.sa-badge.s-sent { background:#ede9fe; color:#5b21b6; }
|
|
.sa-badge.s-completed { background:#dcfce7; color:#166534; }
|
|
.sa-badge.s-cancelled { background:#fee2e2; color:#991b1b; }
|
|
.sa-badge.s-in_progress { background:#fef3c7; color:#92400e; }
|
|
.sa-empty { text-align:center; padding:22px 16px; color:var(--gray-500,#6b7280); font-size:.86rem; }
|
|
.sm-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:30px 16px; }
|
|
.sm-overlay.open { display:flex; }
|
|
.sm-dialog { background:#fff; border-radius:12px; width:100%; max-width:640px; box-shadow:0 20px 50px rgba(0,0,0,.25); }
|
|
.sm-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
|
|
.sm-head h3 { margin:0; font-size:1.05rem; }
|
|
.sm-body { padding:18px 20px; }
|
|
.sm-foot { padding:14px 20px; border-top:1px solid var(--gray-100,#f3f4f6); display:flex; justify-content:flex-end; gap:10px; }
|
|
.sm-field { margin-bottom:14px; }
|
|
.sm-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; }
|
|
.sm-field .hint { font-weight:400; color:var(--gray-500,#6b7280); font-size:.78rem; }
|
|
.sm-field input[type=text], .sm-field input[type=date], .sm-field select, .sm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; background:#fff; }
|
|
.sm-field select[multiple] { min-height:96px; }
|
|
.sm-field textarea { min-height:60px; resize:vertical; }
|
|
.sm-row { display:flex; gap:12px; flex-wrap:wrap; }
|
|
.sm-row > .sm-field { flex:1 1 0; min-width:150px; }
|
|
.sm-err { color:#b91c1c; font-size:.82rem; min-height:18px; }
|
|
.sm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); }
|
|
.hidden { display:none !important; }
|
|
.q-row { display:flex; gap:8px; align-items:flex-start; margin-bottom:8px; }
|
|
.q-row input[type=text] { flex:1 1 auto; }
|
|
.q-row select { flex:0 0 130px; }
|
|
.q-del { background:none; border:1px solid #fecaca; color:#b91c1c; border-radius:6px; padding:6px 9px; cursor:pointer; }
|
|
.lnk-box { background:#f8fafc; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; padding:10px 12px; margin-top:10px; font-size:.82rem; }
|
|
.lnk-item { display:flex; gap:8px; align-items:center; padding:5px 0; border-top:1px solid var(--gray-100,#f3f4f6); }
|
|
.lnk-item:first-child { border-top:none; }
|
|
.lnk-item code { background:#eef2ff; padding:2px 6px; border-radius:5px; font-size:.76rem; flex:1 1 auto; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
.tg-state { font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; }
|
|
.tg-pending { background:#f3f4f6; color:#6b7280; } .tg-sent { background:#ede9fe; color:#5b21b6; }
|
|
.tg-responded, .tg-acknowledged { background:#dcfce7; color:#166534; } .tg-expired { background:#fee2e2; color:#991b1b; }
|
|
</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>
|
|
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js -->
|
|
<div id="topnav"></div>
|
|
|
|
<main class="wrap">
|
|
<nav class="crumb" aria-label="breadcrumb">
|
|
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
|
|
<i data-lucide="chevron-right" class="ic"></i><a href="stakeholders.html">Stakeholder</a>
|
|
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="sact.title">Attività</span>
|
|
</nav>
|
|
<div class="page-head">
|
|
<div>
|
|
<h1 data-i18n="sact.title">Attività stakeholder</h1>
|
|
<p class="sub">Questionari tipo, attività e magic-link verso terze parti (GV.SC-02/05/07 · art. 24 D.Lgs. 138/2024)</p>
|
|
</div>
|
|
</div>
|
|
<div class="content-body">
|
|
<div class="sa-intro">
|
|
<strong>Attività verso gli stakeholder.</strong>
|
|
Crea <em>questionari tipo</em> (da compilare o per firma di avvenuta lettura) collegati a procedure e a misure/requisiti,
|
|
poi pianifica <em>attività</em> assegnandole a un codice stakeholder o a singoli stakeholder, con data e scadenza sul calendario NIS2.
|
|
</div>
|
|
<div class="sa-note">
|
|
Ancoraggio: <strong>GV.SC-02 / GV.SC-05 / GV.SC-07</strong> (coordinamento, requisiti e monitoraggio verso terze parti) —
|
|
obblighi ai sensi dell'<strong>art. 24 D.Lgs. 138/2024</strong>. L'invio email è disattivato: i questionari si condividono tramite magic-link.
|
|
</div>
|
|
|
|
<div class="sa-tabs" role="tablist" aria-label="Sezioni attività">
|
|
<button class="sa-tab active" id="tab-act" role="tab" aria-selected="true" aria-controls="pane-act" onclick="saTab('act')">Attività</button>
|
|
<button class="sa-tab" id="tab-tpl" role="tab" aria-selected="false" aria-controls="pane-tpl" onclick="saTab('tpl')">Questionari tipo</button>
|
|
</div>
|
|
|
|
<!-- ATTIVITÀ -->
|
|
<section id="pane-act" role="tabpanel" aria-labelledby="tab-act">
|
|
<div class="sa-toolbar"><button class="btn btn-primary btn-sm" onclick="actOpen()">+ Nuova attività</button></div>
|
|
<div id="act-list" aria-live="polite"></div>
|
|
</section>
|
|
|
|
<!-- QUESTIONARI TIPO -->
|
|
<section id="pane-tpl" class="hidden" role="tabpanel" aria-labelledby="tab-tpl">
|
|
<div class="sa-toolbar"><button class="btn btn-primary btn-sm" onclick="tplOpen()">+ Nuovo questionario tipo</button></div>
|
|
<div id="tpl-list" aria-live="polite"></div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Modale questionario tipo -->
|
|
<div class="sm-overlay" id="tpl-modal" role="dialog" aria-modal="true" aria-labelledby="tpl-modal-title">
|
|
<div class="sm-dialog">
|
|
<div class="sm-head"><h3 id="tpl-modal-title">Nuovo questionario tipo</h3><button class="sm-close" type="button" aria-label="Chiudi" onclick="tplClose()">×</button></div>
|
|
<div class="sm-body">
|
|
<input type="hidden" id="tpl-id">
|
|
<div class="sm-row">
|
|
<div class="sm-field"><label for="tpl-name">Nome *</label><input type="text" id="tpl-name" maxlength="255"></div>
|
|
<div class="sm-field"><label for="tpl-kind">Tipo *</label>
|
|
<select id="tpl-kind" onchange="tplOnKind()">
|
|
<option value="questionnaire">Questionario da compilare</option>
|
|
<option value="read_ack">Firma di avvenuta lettura</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="sm-field"><label for="tpl-descr">Descrizione</label><textarea id="tpl-descr" maxlength="1000"></textarea></div>
|
|
<div class="sm-field hidden" id="tpl-wrap-content"><label for="tpl-content">Testo da leggere <span class="hint">(per firma di avvenuta lettura)</span></label><textarea id="tpl-content" style="min-height:110px;"></textarea></div>
|
|
<div class="sm-field" id="tpl-wrap-questions">
|
|
<label>Domande</label>
|
|
<div id="tpl-questions"></div>
|
|
<button class="btn btn-outline btn-sm" type="button" onclick="qAdd()">+ Aggiungi domanda</button>
|
|
</div>
|
|
<div class="sm-row">
|
|
<div class="sm-field"><label for="tpl-proc">Procedure collegate</label><select id="tpl-proc" multiple></select></div>
|
|
<div class="sm-field"><label for="tpl-mis">Misure collegate</label><select id="tpl-mis" multiple></select></div>
|
|
</div>
|
|
<div class="sm-field"><label for="tpl-req">Requisiti collegati</label><select id="tpl-req" multiple></select></div>
|
|
<div class="sm-err" id="tpl-err" role="alert"></div>
|
|
</div>
|
|
<div class="sm-foot"><button class="btn btn-outline" type="button" onclick="tplClose()">Annulla</button><button class="btn btn-primary" type="button" id="tpl-save" onclick="tplSave()">Salva</button></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modale attività -->
|
|
<div class="sm-overlay" id="act-modal" role="dialog" aria-modal="true" aria-labelledby="act-modal-title">
|
|
<div class="sm-dialog">
|
|
<div class="sm-head"><h3 id="act-modal-title">Nuova attività</h3><button class="sm-close" type="button" aria-label="Chiudi" onclick="actClose()">×</button></div>
|
|
<div class="sm-body">
|
|
<input type="hidden" id="act-id">
|
|
<div class="sm-row">
|
|
<div class="sm-field"><label for="act-title">Titolo *</label><input type="text" id="act-title" maxlength="255"></div>
|
|
<div class="sm-field"><label for="act-type">Tipo *</label>
|
|
<select id="act-type">
|
|
<option value="questionnaire">Questionario</option>
|
|
<option value="read_ack">Firma di avvenuta lettura</option>
|
|
<option value="action">Azione</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="sm-field"><label for="act-template">Questionario tipo <span class="hint">(opzionale)</span></label><select id="act-template"><option value="">— Nessuno —</option></select></div>
|
|
<div class="sm-field"><label for="act-descr">Descrizione</label><textarea id="act-descr" maxlength="1000"></textarea></div>
|
|
<div class="sm-row">
|
|
<div class="sm-field"><label for="act-mode">Assegnazione *</label>
|
|
<select id="act-mode" onchange="actOnMode()">
|
|
<option value="by_code">Per codice stakeholder</option>
|
|
<option value="individual">Stakeholder singoli</option>
|
|
</select>
|
|
</div>
|
|
<div class="sm-field" id="act-wrap-code"><label for="act-code">Codice stakeholder</label><select id="act-code"></select></div>
|
|
</div>
|
|
<div class="sm-field hidden" id="act-wrap-individual"><label for="act-stakeholders">Stakeholder <span class="hint">(Ctrl/Cmd per più selezioni)</span></label><select id="act-stakeholders" multiple></select></div>
|
|
<div class="sm-row">
|
|
<div class="sm-field"><label for="act-planned">Data pianificata</label><input type="date" id="act-planned"></div>
|
|
<div class="sm-field"><label for="act-due">Scadenza <span class="hint">(→ calendario NIS2)</span></label><input type="date" id="act-due"></div>
|
|
</div>
|
|
<div class="sm-field"><label for="act-proc">Procedure collegate</label><select id="act-proc" multiple></select></div>
|
|
<div class="sm-err" id="act-err" role="alert"></div>
|
|
</div>
|
|
<div class="sm-foot"><button class="btn btn-outline" type="button" onclick="actClose()">Annulla</button><button class="btn btn-primary" type="button" id="act-save" onclick="actSave()">Salva</button></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modale dettaglio attività (target + invio) -->
|
|
<div class="sm-overlay" id="det-modal" role="dialog" aria-modal="true" aria-labelledby="det-title">
|
|
<div class="sm-dialog">
|
|
<div class="sm-head"><h3 id="det-title">Dettaglio attività</h3><button class="sm-close" type="button" aria-label="Chiudi" onclick="detClose()">×</button></div>
|
|
<div class="sm-body" id="det-body"></div>
|
|
<div class="sm-foot">
|
|
<button class="btn btn-outline" type="button" onclick="detClose()">Chiudi</button>
|
|
<button class="btn btn-outline" type="button" id="det-assign" onclick="detAssign()">Assegna destinatari</button>
|
|
<button class="btn btn-primary" type="button" id="det-send" onclick="detSend()">Invia (genera magic-link)</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
|
|
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → init pagina -->
|
|
<script src="/js/api.js?v=20260730a"></script>
|
|
<script src="/js/common.js?v=20260729b"></script>
|
|
<script src="/vendor/lucide/lucide.min.js"></script>
|
|
<script src="/js/topnav-v3.js?v=20260729"></script>
|
|
<script src="/js/i18n.js?v=20260730b"></script>
|
|
<script src="/js/help.js?v=20260730b"></script>
|
|
<script>
|
|
'use strict';
|
|
/*
|
|
* NIS2 Agile - Attività stakeholder (Epic C / C5.2a)
|
|
* Questionari tipo (template) + attività + assegnazione (per codice / singoli) + invio
|
|
* con magic-link (email disattivate). Sotto-dashboard feedback completa in C5.2b.
|
|
*/
|
|
let SA = { activities: [], templates: [], pickers: { policies: [], misure: [], requisiti: [] }, types: [], stakeholders: [], detail: null };
|
|
const ST_LABELS = { draft: 'Bozza', scheduled: 'Pianificata', sent: 'Inviata', in_progress: 'In corso', completed: 'Completata', cancelled: 'Annullata' };
|
|
const TYPE_LABELS = { questionnaire: 'Questionario', read_ack: 'Firma lettura', action: 'Azione' };
|
|
|
|
function el(id) { return document.getElementById(id); }
|
|
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
|
|
|
document.addEventListener('DOMContentLoaded', async function () {
|
|
if (typeof checkAuth === 'function' && !checkAuth()) return;
|
|
if (window.I18n && I18n.init) I18n.init('it');
|
|
if (typeof loadSidebar === 'function') loadSidebar(); // = loadTopnavV3()
|
|
if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
|
|
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') { tplClose(); actClose(); detClose(); } });
|
|
['tpl-modal','act-modal','det-modal'].forEach(m => { const o = el(m); if (o) o.addEventListener('click', e => { if (e.target === o) o.classList.remove('open'); }); });
|
|
await saLoadAll();
|
|
});
|
|
|
|
function saTab(which) {
|
|
el('tab-act').classList.toggle('active', which === 'act');
|
|
el('tab-tpl').classList.toggle('active', which === 'tpl');
|
|
el('tab-act').setAttribute('aria-selected', which === 'act' ? 'true' : 'false');
|
|
el('tab-tpl').setAttribute('aria-selected', which === 'tpl' ? 'true' : 'false');
|
|
el('pane-act').classList.toggle('hidden', which !== 'act');
|
|
el('pane-tpl').classList.toggle('hidden', which !== 'tpl');
|
|
}
|
|
|
|
async function saLoadAll() {
|
|
el('act-list').innerHTML = '<div class="sa-empty">Caricamento…</div>';
|
|
el('tpl-list').innerHTML = '<div class="sa-empty">Caricamento…</div>';
|
|
try {
|
|
const [acts, tpls, pickers, types, stks] = await Promise.all([
|
|
api.stkActList(), api.stkActTemplates(), api.stkActPickers(), api.stkTypes(), api.stkList()
|
|
]);
|
|
SA.activities = (acts && acts.activities) || [];
|
|
SA.templates = (tpls && tpls.templates) || [];
|
|
SA.pickers = pickers || SA.pickers;
|
|
SA.types = (types && types.types) || [];
|
|
SA.stakeholders = (stks && stks.stakeholders) || [];
|
|
fillFormSelects();
|
|
renderActivities();
|
|
renderTemplates();
|
|
} catch (e) {
|
|
el('act-list').innerHTML = '<div class="sa-empty">Errore: ' + esc(e.message || e) + '</div>';
|
|
}
|
|
}
|
|
|
|
function fillFormSelects() {
|
|
const polOpts = (SA.pickers.policies || []).map(p => '<option value="' + p.id + '">' + esc(p.title) + '</option>').join('');
|
|
el('tpl-proc').innerHTML = polOpts || '<option disabled>Nessuna procedura</option>';
|
|
el('act-proc').innerHTML = polOpts || '<option disabled>Nessuna procedura</option>';
|
|
el('tpl-mis').innerHTML = (SA.pickers.misure || []).map(m => '<option value="' + esc(m.code) + '">' + esc(m.code) + ' — ' + esc((m.descr || '').slice(0, 60)) + '</option>').join('');
|
|
el('tpl-req').innerHTML = (SA.pickers.requisiti || []).map(r => '<option value="' + r.id + '">' + esc(r.code) + ' (' + esc(r.misura_code) + ')</option>').join('');
|
|
el('act-template').innerHTML = '<option value="">— Nessuno —</option>' + SA.templates.map(t => '<option value="' + t.id + '">' + esc(t.name) + '</option>').join('');
|
|
el('act-code').innerHTML = SA.types.map(t => '<option value="' + esc(t.code) + '">' + esc(t.code) + ' — ' + esc((t.descr || '').slice(0, 50)) + '</option>').join('');
|
|
el('act-stakeholders').innerHTML = SA.stakeholders.map(s => '<option value="' + s.id + '">' + esc(s.name) + ' (' + esc(s.stak_code) + ')</option>').join('') || '<option disabled>Nessuno stakeholder: creane nel registro</option>';
|
|
}
|
|
|
|
// ── Questionari tipo ──
|
|
function renderTemplates() {
|
|
if (!SA.templates.length) { el('tpl-list').innerHTML = '<div class="sa-empty">Nessun questionario tipo. Creane uno con "+ Nuovo questionario tipo".</div>'; return; }
|
|
const rows = SA.templates.map(t =>
|
|
'<tr><td><strong>' + esc(t.name) + '</strong>' + (t.description ? '<div style="font-size:.78rem;color:#6b7280;">' + esc(t.description.slice(0, 90)) + '</div>' : '') + '</td>'
|
|
+ '<td><span class="sa-badge k-' + t.kind + '">' + (t.kind === 'read_ack' ? 'Firma lettura' : 'Questionario') + '</span></td>'
|
|
+ '<td>' + (t.kind === 'read_ack' ? '—' : (t.n_questions + ' dom.')) + '</td>'
|
|
+ '<td style="font-size:.8rem;">' + t.n_proc + ' proc · ' + t.n_mis + ' mis · ' + t.n_req + ' req</td>'
|
|
+ '<td style="white-space:nowrap;"><button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="tplOpen(' + t.id + ')">Modifica</button> '
|
|
+ '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;color:#b91c1c;border-color:#fecaca;" onclick="tplDelete(' + t.id + ')">Elimina</button></td></tr>'
|
|
).join('');
|
|
el('tpl-list').innerHTML = '<table class="sa-table"><thead><tr><th>Nome</th><th>Tipo</th><th>Domande</th><th>Collegamenti</th><th>Azioni</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
}
|
|
|
|
function qAdd(text, type, required, options) {
|
|
const allowed = ['text','yes_no','single_choice','multi_choice','scale_1_5','number'];
|
|
const t = allowed.indexOf(type) !== -1 ? type : 'text';
|
|
const isChoice = (t === 'single_choice' || t === 'multi_choice');
|
|
const div = document.createElement('div'); div.className = 'q-row'; div.style.flexWrap = 'wrap';
|
|
div.innerHTML = '<input type="text" class="q-text-in" placeholder="Testo della domanda" maxlength="500" value="' + esc(text || '').replace(/"/g,'"') + '">'
|
|
+ '<select class="q-type-in" onchange="qToggleOpts(this)">' + allowed.map(a => '<option value="' + a + '"' + (a === t ? ' selected' : '') + '>' + ({text:'Testo',yes_no:'Sì/No',single_choice:'Scelta singola',multi_choice:'Scelta multipla',scale_1_5:'Scala 1-5',number:'Numero'})[a] + '</option>').join('') + '</select>'
|
|
+ '<label style="font-size:.78rem;display:flex;align-items:center;gap:4px;white-space:nowrap;"><input type="checkbox" class="q-req-in"' + (required ? ' checked' : '') + '> obbl.</label>'
|
|
+ '<button type="button" class="q-del" onclick="this.closest(\'.q-row\').remove()">✕</button>'
|
|
+ '<input type="text" class="q-opts-in" placeholder="Opzioni separate da | (es. Sì | No | In parte)" maxlength="500" style="flex:1 1 100%;margin-top:6px;' + (isChoice ? '' : 'display:none;') + '" value="' + esc((options || []).join(' | ')).replace(/"/g,'"') + '">';
|
|
el('tpl-questions').appendChild(div);
|
|
}
|
|
function qToggleOpts(sel) {
|
|
const row = sel.closest('.q-row'); const o = row.querySelector('.q-opts-in');
|
|
o.style.display = (sel.value === 'single_choice' || sel.value === 'multi_choice') ? '' : 'none';
|
|
}
|
|
|
|
function tplOnKind() {
|
|
const k = el('tpl-kind').value;
|
|
el('tpl-wrap-content').classList.toggle('hidden', k !== 'read_ack');
|
|
el('tpl-wrap-questions').classList.toggle('hidden', k === 'read_ack');
|
|
}
|
|
|
|
async function tplOpen(id) {
|
|
el('tpl-id').value = id || '';
|
|
el('tpl-modal-title').textContent = id ? 'Modifica questionario tipo' : 'Nuovo questionario tipo';
|
|
el('tpl-err').textContent = '';
|
|
el('tpl-questions').innerHTML = '';
|
|
['tpl-proc','tpl-mis','tpl-req'].forEach(s => Array.prototype.forEach.call(el(s).options, o => o.selected = false));
|
|
if (id) {
|
|
try {
|
|
const t = await api.stkActGetTemplate(id);
|
|
el('tpl-name').value = t.name || ''; el('tpl-kind').value = t.kind || 'questionnaire';
|
|
el('tpl-descr').value = t.description || ''; el('tpl-content').value = t.content || '';
|
|
(t.questions || []).forEach(q => qAdd(q.text, q.type, q.required, q.options));
|
|
selMulti('tpl-proc', t.policy_ids || []); selMulti('tpl-mis', t.misura_codes || []); selMulti('tpl-req', t.requisito_ids || []);
|
|
} catch (e) { el('tpl-err').textContent = e.message || 'Errore'; }
|
|
} else {
|
|
el('tpl-name').value = ''; el('tpl-kind').value = 'questionnaire'; el('tpl-descr').value = ''; el('tpl-content').value = '';
|
|
}
|
|
tplOnKind();
|
|
el('tpl-modal').classList.add('open');
|
|
}
|
|
function tplClose() { el('tpl-modal').classList.remove('open'); }
|
|
|
|
function collectQuestions() {
|
|
return Array.prototype.map.call(el('tpl-questions').children, (row, i) => {
|
|
const text = row.querySelector('.q-text-in').value.trim();
|
|
const type = row.querySelector('.q-type-in').value;
|
|
const required = row.querySelector('.q-req-in').checked;
|
|
const q = { code: 'Q' + (i + 1), text: text, type: type, required: required };
|
|
if (type === 'single_choice' || type === 'multi_choice') {
|
|
q.options = (row.querySelector('.q-opts-in').value || '').split('|').map(s => s.trim()).filter(Boolean);
|
|
}
|
|
return q;
|
|
}).filter(q => q.text !== '');
|
|
}
|
|
|
|
async function tplSave() {
|
|
const id = el('tpl-id').value;
|
|
const name = el('tpl-name').value.trim();
|
|
if (!name) { el('tpl-err').textContent = 'Il nome è obbligatorio.'; return; }
|
|
const kind = el('tpl-kind').value;
|
|
const payload = {
|
|
name: name, kind: kind, description: el('tpl-descr').value.trim(),
|
|
content: kind === 'read_ack' ? el('tpl-content').value.trim() : null,
|
|
questions: kind === 'questionnaire' ? collectQuestions() : [],
|
|
policy_ids: getMulti('tpl-proc'), misura_codes: getMulti('tpl-mis', true), requisito_ids: getMulti('tpl-req'),
|
|
};
|
|
const btn = el('tpl-save'); btn.disabled = true;
|
|
try {
|
|
if (id) await api.stkActUpdateTemplate(parseInt(id, 10), payload); else await api.stkActCreateTemplate(payload);
|
|
showNotification(id ? 'Questionario tipo aggiornato.' : 'Questionario tipo creato.', 'success');
|
|
tplClose(); await saLoadAll();
|
|
} catch (e) { el('tpl-err').textContent = e.message || 'Errore nel salvataggio.'; } finally { btn.disabled = false; }
|
|
}
|
|
|
|
async function tplDelete(id) {
|
|
const t = SA.templates.find(x => x.id === id);
|
|
if (!confirm('Eliminare il questionario tipo "' + (t ? t.name : '') + '"?')) return;
|
|
try { await api.stkActDeleteTemplate(id); showNotification('Eliminato.', 'success'); await saLoadAll(); }
|
|
catch (e) { showNotification(e.message || 'Errore', 'error'); }
|
|
}
|
|
|
|
// ── Attività ──
|
|
function renderActivities() {
|
|
if (!SA.activities.length) { el('act-list').innerHTML = '<div class="sa-empty">Nessuna attività. Creane una con "+ Nuova attività".</div>'; return; }
|
|
const rows = SA.activities.map(a =>
|
|
'<tr><td><strong>' + esc(a.title) + '</strong>' + (a.template_name ? '<div style="font-size:.78rem;color:#6b7280;">' + esc(a.template_name) + '</div>' : '') + '</td>'
|
|
+ '<td><span class="sa-badge k-' + a.type + '">' + (TYPE_LABELS[a.type] || a.type) + '</span></td>'
|
|
+ '<td style="font-size:.82rem;">' + (a.assign_mode === 'by_code' ? ('Codice ' + esc(a.stak_code || '?')) : 'Singoli') + '</td>'
|
|
+ '<td style="font-size:.82rem;">' + (a.due_date ? esc(a.due_date) : '—') + '</td>'
|
|
+ '<td><span class="sa-badge s-' + a.status + '">' + esc(ST_LABELS[a.status] || a.status) + '</span></td>'
|
|
+ '<td style="font-size:.82rem;">' + a.n_done + '/' + a.n_targets + '</td>'
|
|
+ '<td style="white-space:nowrap;"><button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="detOpen(' + a.id + ')">Dettaglio</button> '
|
|
+ '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="actOpen(' + a.id + ')">Modifica</button> '
|
|
+ '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;color:#b91c1c;border-color:#fecaca;" onclick="actDelete(' + a.id + ')">Elimina</button></td></tr>'
|
|
).join('');
|
|
el('act-list').innerHTML = '<table class="sa-table"><thead><tr><th>Titolo</th><th>Tipo</th><th>Assegnazione</th><th>Scadenza</th><th>Stato</th><th>Risposte</th><th>Azioni</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
}
|
|
|
|
function actOnMode() {
|
|
const m = el('act-mode').value;
|
|
el('act-wrap-code').classList.toggle('hidden', m !== 'by_code');
|
|
el('act-wrap-individual').classList.toggle('hidden', m !== 'individual');
|
|
}
|
|
|
|
async function actOpen(id) {
|
|
el('act-id').value = id || '';
|
|
el('act-modal-title').textContent = id ? 'Modifica attività' : 'Nuova attività';
|
|
el('act-err').textContent = '';
|
|
let a = null;
|
|
if (id) { try { a = await api.stkActGet(id); } catch (e) { el('act-err').textContent = e.message; } }
|
|
el('act-title').value = a ? a.title : '';
|
|
el('act-type').value = a ? a.type : 'questionnaire';
|
|
el('act-template').value = a && a.template_id ? String(a.template_id) : '';
|
|
el('act-descr').value = a ? (a.description || '') : '';
|
|
el('act-mode').value = a ? a.assign_mode : 'by_code';
|
|
el('act-code').value = a && a.stak_code ? a.stak_code : (el('act-code').querySelector('option') ? el('act-code').querySelector('option').value : '');
|
|
el('act-planned').value = a ? (a.planned_date || '') : '';
|
|
el('act-due').value = a ? (a.due_date || '') : '';
|
|
selMulti('act-proc', a ? (a.policy_ids || []) : []);
|
|
// gli stakeholder individuali correnti = i target esistenti
|
|
selMulti('act-stakeholders', a && a.targets ? a.targets.map(t => t.stakeholder_id) : []);
|
|
actOnMode();
|
|
el('act-modal').classList.add('open');
|
|
}
|
|
function actClose() { el('act-modal').classList.remove('open'); }
|
|
|
|
async function actSave() {
|
|
const id = el('act-id').value;
|
|
const title = el('act-title').value.trim();
|
|
if (!title) { el('act-err').textContent = 'Il titolo è obbligatorio.'; return; }
|
|
const mode = el('act-mode').value;
|
|
const payload = {
|
|
title: title, type: el('act-type').value,
|
|
template_id: el('act-template').value ? parseInt(el('act-template').value, 10) : null,
|
|
description: el('act-descr').value.trim(),
|
|
assign_mode: mode, stak_code: mode === 'by_code' ? el('act-code').value : null,
|
|
planned_date: el('act-planned').value || null, due_date: el('act-due').value || null,
|
|
policy_ids: getMulti('act-proc'),
|
|
};
|
|
const indIds = mode === 'individual' ? getMulti('act-stakeholders') : [];
|
|
if (mode === 'individual' && !indIds.length) { el('act-err').textContent = 'Seleziona almeno uno stakeholder.'; return; }
|
|
const btn = el('act-save'); btn.disabled = true;
|
|
try {
|
|
let actId;
|
|
if (id) { await api.stkActUpdate(parseInt(id, 10), payload); actId = parseInt(id, 10); }
|
|
else { const r = await api.stkActCreate(payload); actId = r.id; el('act-id').value = actId; } // evita create duplicati su retry
|
|
// assegnazione immediata; se fallisce, l'attività esiste già: NON ricreare
|
|
try {
|
|
await api.stkActAssign(actId, mode === 'individual' ? { stakeholder_ids: indIds } : {});
|
|
} catch (assignErr) {
|
|
await saLoadAll();
|
|
el('act-err').textContent = 'Attività salvata, ma assegnazione destinatari non riuscita: ' + (assignErr.message || '') + ' — riprova o assegna dal dettaglio.';
|
|
return;
|
|
}
|
|
showNotification(id ? 'Attività aggiornata.' : 'Attività creata e destinatari assegnati.', 'success');
|
|
actClose(); await saLoadAll();
|
|
} catch (e) { el('act-err').textContent = e.message || 'Errore nel salvataggio.'; } finally { btn.disabled = false; }
|
|
}
|
|
|
|
async function actDelete(id) {
|
|
const a = SA.activities.find(x => x.id === id);
|
|
if (!confirm('Eliminare l\'attività "' + (a ? a.title : '') + '"?')) return;
|
|
try { await api.stkActDelete(id); showNotification('Eliminata.', 'success'); await saLoadAll(); }
|
|
catch (e) { showNotification(e.message || 'Errore', 'error'); }
|
|
}
|
|
|
|
// ── Dettaglio + invio ──
|
|
async function detOpen(id) {
|
|
el('det-body').innerHTML = '<div class="sa-empty">Caricamento…</div>';
|
|
el('det-modal').classList.add('open');
|
|
try {
|
|
const [a, fb, cm, at] = await Promise.all([
|
|
api.stkActGet(id), api.stkActFeedback(id), api.stkActComments(id), api.stkActAttachments(id)
|
|
]);
|
|
SA.detail = a;
|
|
SA.feedback = fb; SA.comments = (cm && cm.comments) || []; SA.atts = (at && at.attachments) || [];
|
|
detRender(a);
|
|
} catch (e) { el('det-body').innerHTML = '<div class="sa-empty">Errore: ' + esc(e.message) + '</div>'; }
|
|
}
|
|
function detClose() { el('det-modal').classList.remove('open'); SA.detail = null; }
|
|
|
|
function detRender(a, links) {
|
|
el('det-title').textContent = 'Dettaglio: ' + a.title;
|
|
const fb = SA.feedback || { template: null, targets: [] };
|
|
const tmap = {}; (fb.targets || []).forEach(t => { tmap[t.target_id] = t; });
|
|
const targets = a.targets || [];
|
|
let html = '<div id="det-answers"></div><div style="font-size:.85rem;margin-bottom:10px;color:#374151;">Tipo: <strong>' + esc(TYPE_LABELS[a.type] || a.type) + '</strong> · Scadenza: ' + esc(a.due_date || '—') + ' · Stato: ' + esc(ST_LABELS[a.status] || a.status) + '</div>';
|
|
|
|
// Destinatari + esito
|
|
if (!targets.length) {
|
|
html += '<div class="sa-empty">Nessun destinatario assegnato. Usa "Assegna destinatari".</div>';
|
|
} else {
|
|
html += '<table class="sa-table"><thead><tr><th>Stakeholder</th><th>Codice</th><th>Stato</th><th>Esito</th></tr></thead><tbody>'
|
|
+ targets.map(t => {
|
|
const ft = tmap[t.id] || {};
|
|
let outcome = '—';
|
|
if (ft.acknowledged_at) outcome = 'Firmato il ' + esc((ft.acknowledged_at || '').slice(0, 16));
|
|
else if (ft.answers) outcome = '<button class="btn btn-outline" style="padding:2px 8px;font-size:.72rem;" onclick="detAnswers(' + t.id + ')">Vedi risposte</button>';
|
|
return '<tr><td>' + esc(t.stakeholder_name) + '</td><td>' + esc(t.stak_code) + '</td><td><span class="tg-state tg-' + t.state + '">' + esc(t.state) + '</span></td><td style="font-size:.8rem;">' + outcome + '</td></tr>';
|
|
}).join('')
|
|
+ '</tbody></table>';
|
|
}
|
|
|
|
// Magic-link (dopo send)
|
|
if (links && links.length) {
|
|
html += '<div class="lnk-box"><strong>Magic-link generati</strong> (email disattivate: copia e condividi manualmente):'
|
|
+ links.map(l => '<div class="lnk-item"><span style="flex:0 0 120px;">' + esc(l.stakeholder_name) + '</span><code id="lnk-' + l.target_id + '">' + esc(location.origin + l.magic_link) + '</code>'
|
|
+ '<button class="btn btn-outline" style="padding:2px 8px;font-size:.72rem;" onclick="lnkCopy(' + l.target_id + ')">Copia</button></div>').join('')
|
|
+ '</div>';
|
|
}
|
|
|
|
// Commenti
|
|
html += '<h4 style="margin:16px 0 6px;font-size:.82rem;text-transform:uppercase;letter-spacing:.04em;color:#6b7280;">Commenti</h4>';
|
|
html += (SA.comments && SA.comments.length)
|
|
? '<div>' + SA.comments.map(c => '<div style="font-size:.84rem;padding:6px 0;border-top:1px solid #f3f4f6;"><strong>' + esc(c.author) + '</strong> <span style="color:#9ca3af;font-size:.76rem;">' + esc((c.created_at || '').slice(0, 16)) + (c.author_kind === 'external' ? ' · esterno' : '') + '</span><div>' + esc(c.body) + '</div></div>').join('') + '</div>'
|
|
: '<div style="font-size:.82rem;color:#9ca3af;">Nessun commento.</div>';
|
|
html += '<div style="display:flex;gap:8px;margin-top:8px;"><input type="text" id="det-comment" placeholder="Aggiungi un commento" style="flex:1 1 auto;padding:8px 10px;border:1px solid #e5e7eb;border-radius:8px;font-size:.86rem;"><button class="btn btn-outline" style="font-size:.78rem;" onclick="detComment()">Invia</button></div>';
|
|
|
|
// Allegati
|
|
html += '<h4 style="margin:16px 0 6px;font-size:.82rem;text-transform:uppercase;letter-spacing:.04em;color:#6b7280;">Allegati</h4>';
|
|
html += (SA.atts && SA.atts.length)
|
|
? '<ul style="list-style:none;padding:0;margin:0;">' + SA.atts.map(f => '<li style="font-size:.84rem;padding:4px 0;"><a href="' + esc(f.url) + '" target="_blank" rel="noopener">' + esc(f.file_name) + '</a> <span style="color:#9ca3af;font-size:.76rem;">(' + Math.round((f.file_size || 0) / 1024) + ' KB)</span></li>').join('') + '</ul>'
|
|
: '<div style="font-size:.82rem;color:#9ca3af;">Nessun allegato.</div>';
|
|
html += '<div style="margin-top:8px;"><input type="file" id="det-file"> <button class="btn btn-outline" style="font-size:.78rem;" onclick="detUpload()">Carica allegato</button></div>';
|
|
|
|
el('det-body').innerHTML = html;
|
|
}
|
|
|
|
function detAnswers(targetId) {
|
|
const fb = SA.feedback || {}; const t = (fb.targets || []).find(x => x.target_id === targetId);
|
|
const box = el('det-answers'); if (!box) return;
|
|
if (!t || !t.answers) { box.innerHTML = ''; showNotification('Nessuna risposta.', 'info'); return; }
|
|
const qs = (fb.template && fb.template.questions) || [];
|
|
const label = {}; qs.forEach(q => { label[q.code] = q.text; });
|
|
const rows = Object.keys(t.answers).map(k => {
|
|
const v = t.answers[k]; const sv = Array.isArray(v) ? v.join(', ') : (v == null ? '' : v);
|
|
return '<div style="padding:4px 0;border-top:1px solid #eef2ff;"><div style="font-weight:600;font-size:.82rem;">' + esc(label[k] || k) + '</div><div style="font-size:.86rem;">' + esc(sv || '—') + '</div></div>';
|
|
}).join('');
|
|
box.innerHTML = '<div style="background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;padding:10px 12px;margin-bottom:12px;">'
|
|
+ '<div style="display:flex;justify-content:space-between;align-items:center;"><strong style="font-size:.85rem;">Risposte di ' + esc(t.respondent_name || t.stakeholder_name) + '</strong>'
|
|
+ '<button class="btn btn-outline" style="padding:1px 8px;font-size:.72rem;" onclick="el(\'det-answers\').innerHTML=\'\'">Chiudi</button></div>'
|
|
+ rows + '</div>';
|
|
box.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
|
|
async function detComment() {
|
|
const v = (el('det-comment') || {}).value || '';
|
|
if (!v.trim()) return;
|
|
try { await api.stkActAddComment(SA.detail.id, { body: v }); SA.comments = ((await api.stkActComments(SA.detail.id)).comments) || []; detRender(SA.detail); }
|
|
catch (e) { showNotification(e.message || 'Errore', 'error'); }
|
|
}
|
|
|
|
async function detUpload() {
|
|
const f = el('det-file'); if (!f || !f.files || !f.files[0]) { showNotification('Seleziona un file.', 'info'); return; }
|
|
const fd = new FormData(); fd.append('file', f.files[0]);
|
|
try {
|
|
const r = await fetch('/api/stakeholder-activities/' + SA.detail.id + '/attachments', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('nis2_access_token') || ''), 'X-Organization-Id': (localStorage.getItem('nis2_org_id') || '') },
|
|
body: fd
|
|
});
|
|
const j = await r.json();
|
|
if (!j.success) { showNotification(j.message || 'Errore upload', 'error'); return; }
|
|
SA.atts = ((await api.stkActAttachments(SA.detail.id)).attachments) || []; detRender(SA.detail);
|
|
showNotification('Allegato caricato.', 'success');
|
|
} catch (e) { showNotification('Errore upload.', 'error'); }
|
|
}
|
|
|
|
function lnkCopy(tid) {
|
|
const c = el('lnk-' + tid); if (!c) return;
|
|
navigator.clipboard && navigator.clipboard.writeText(c.textContent).then(() => showNotification('Link copiato.', 'success'), () => {});
|
|
}
|
|
|
|
async function detAssign() {
|
|
if (!SA.detail) return;
|
|
const btn = el('det-assign'); btn.disabled = true;
|
|
try {
|
|
// per by_code basta richiamare assign; per individual riusa i target correnti (no-op) → suggerisci modifica
|
|
const r = await api.stkActAssign(SA.detail.id, SA.detail.assign_mode === 'individual' ? { stakeholder_ids: (SA.detail.targets || []).map(t => t.stakeholder_id) } : {});
|
|
showNotification('Destinatari aggiornati (' + r.total_targets + ').', 'success');
|
|
await detOpen(SA.detail.id); await saLoadAll();
|
|
} catch (e) { showNotification(e.message || 'Errore', 'error'); } finally { btn.disabled = false; }
|
|
}
|
|
|
|
async function detSend() {
|
|
if (!SA.detail) return;
|
|
if (!confirm('Generare i magic-link per tutti i destinatari? (le email sono disattivate, i link andranno condivisi a mano)')) return;
|
|
const btn = el('det-send'); btn.disabled = true;
|
|
try {
|
|
const r = await api.stkActSend(SA.detail.id);
|
|
showNotification('Inviata: ' + r.sent + ' magic-link generati.', 'success');
|
|
const a = await api.stkActGet(SA.detail.id); SA.detail = a;
|
|
detRender(a, r.links);
|
|
await saLoadAll();
|
|
} catch (e) { showNotification(e.message || 'Errore', 'error'); } finally { btn.disabled = false; }
|
|
}
|
|
|
|
// ── util multi-select ──
|
|
function getMulti(id, asString) {
|
|
return Array.prototype.filter.call(el(id).options, o => o.selected).map(o => asString ? o.value : parseInt(o.value, 10)).filter(v => asString ? v : !isNaN(v));
|
|
}
|
|
function selMulti(id, values) {
|
|
const set = new Set((values || []).map(String));
|
|
Array.prototype.forEach.call(el(id).options, o => { o.selected = set.has(String(o.value)); });
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|