Pagina connettori-discovery.html + connettori-discovery.js (DiscoveryUI): lista connettori, crea (mostra la chiave API una sola volta + comando agente), dettaglio con storico run, rigenera chiave, elimina. Solo org_admin. - Voce sidebar V2 "Connettori Discovery" in Operativo (common-bi.js, data-nav auto). - api.js: metodi list/create/get/update/delete/rotateDiscoveryKey. - ARIA navigationMapBlock: aggiunta la voce (coerenza assistente↔menu). - help.js: _pageMap + guida contestuale "Connettori Discovery" (multi-connettore, come funziona, sicurezza scope ingest:assets). Smoke: pagina 200, /api/discovery-connectors/list 401 (auth). Cache-buster common-bi/help/api -> 20260625d. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
425 lines
26 KiB
HTML
425 lines
26 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>Riesame di Direzione - NIS2 Agile</title>
|
|
<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:12px; 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-toolbar { display:flex; justify-content:flex-end; margin-bottom:12px; }
|
|
.mr-table { width:100%; border-collapse:collapse; font-size:.88rem; }
|
|
.mr-table th, .mr-table td { text-align:left; padding:9px 12px; border-top: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); }
|
|
.mr-table tr.click { cursor:pointer; } .mr-table tr.click:hover { background:#f8fafc; }
|
|
.mr-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; white-space:nowrap; }
|
|
.mr-badge.s-draft { background:#f3f4f6; color:#6b7280; }
|
|
.mr-badge.s-approved { background:#dcfce7; color:#166534; }
|
|
.mr-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:820px; 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; flex-wrap:wrap; }
|
|
.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 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; }
|
|
.mr-section { border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; margin-bottom:10px; }
|
|
.mr-section h4 { margin:0; padding:10px 14px; font-size:.86rem; background:#f8fafc; border-bottom:1px solid var(--gray-100,#f3f4f6); }
|
|
.mr-section .body { padding:10px 14px; font-size:.84rem; }
|
|
.mr-section .body ul { margin:6px 0 0; padding-left:18px; }
|
|
.mr-section.na h4 { color:#9ca3af; }
|
|
.mr-pill { display:inline-block; background:#eef2ff; color:#3730a3; font-size:.72rem; font-weight:700; padding:1px 8px; border-radius:6px; margin-left:6px; }
|
|
.dec-row { display:flex; gap:8px; align-items:flex-start; margin-bottom:8px; flex-wrap:wrap; }
|
|
.dec-row .grow { flex:1 1 200px; } .dec-row select, .dec-row input[type=date] { padding:7px 9px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.84rem; }
|
|
.dec-del { background:none; border:1px solid #fecaca; color:#b91c1c; border-radius:6px; padding:6px 9px; cursor:pointer; }
|
|
.dec-stat { font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; }
|
|
.dec-open { background:#f3f4f6; color:#6b7280; } .dec-in_progress { background:#fef3c7; color:#92400e; } .dec-done { background:#dcfce7; color:#166534; }
|
|
</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="mreview.title">Riesame di Direzione</h2>
|
|
</header>
|
|
<div class="content-body">
|
|
<div class="mr-intro">
|
|
<strong>Riesame periodico del SGSI da parte della direzione.</strong>
|
|
Produce il <em>verbale</em> con gli elementi in ingresso (aggregati automaticamente dai moduli esistenti) e in uscita
|
|
(le <em>decisioni</em>: azioni con responsabile e scadenza). Una volta approvato, il verbale viene congelato e reso immutabile.
|
|
</div>
|
|
<div class="mr-note">
|
|
Ancoraggio: <strong>ISO/IEC 27001 §9.3</strong> (riesame di direzione) e buona prassi di governance NIS2 (<strong>GV.PO-02</strong>,
|
|
art. 23 D.Lgs. 138/2024). Strumento di supporto e pre-audit: non sostituisce l'auditor.
|
|
</div>
|
|
|
|
<div class="mr-toolbar"><button class="btn btn-primary btn-sm" onclick="mrOpen()">+ Nuovo riesame</button></div>
|
|
<div id="mr-list" aria-live="polite"></div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Modale dettaglio / verbale -->
|
|
<div class="sm-overlay" id="mr-modal" role="dialog" aria-modal="true" aria-labelledby="mr-modal-title">
|
|
<div class="sm-dialog">
|
|
<div class="sm-head"><h3 id="mr-modal-title">Nuovo riesame</h3><button class="sm-close" type="button" aria-label="Chiudi" onclick="mrClose()">×</button></div>
|
|
<div class="sm-body">
|
|
<input type="hidden" id="mr-id">
|
|
<div class="sm-row">
|
|
<div class="sm-field"><label for="mr-date">Data riesame</label><input type="date" id="mr-date"></div>
|
|
<div class="sm-field"><label for="mr-period">Periodo di riferimento <span class="hint">(es. 2026 H1)</span></label><input type="text" id="mr-period" maxlength="100"></div>
|
|
</div>
|
|
<div class="sm-field"><label for="mr-chair">Presidente del riesame</label><select id="mr-chair"><option value="">— Nessuno —</option></select></div>
|
|
<div class="sm-field"><label for="mr-attendees">Partecipanti <span class="hint">(uno per riga: Nome — ruolo)</span></label><textarea id="mr-attendees" placeholder="Mario Rossi — CISO Anna Bianchi — DPO"></textarea></div>
|
|
|
|
<div class="sm-field">
|
|
<label>Elementi in ingresso (§9.3.2)</label>
|
|
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
|
<button class="btn btn-outline btn-sm" type="button" id="mr-gather-btn" onclick="mrGather()">Aggrega dati</button>
|
|
<span class="hint" id="mr-gather-info"></span>
|
|
</div>
|
|
<div id="mr-inputs"><div class="mr-empty">Premi "Aggrega dati" per raccogliere automaticamente gli input dai moduli.</div></div>
|
|
</div>
|
|
|
|
<div class="sm-field"><label for="mr-conclusions">Conclusioni</label><textarea id="mr-conclusions" style="min-height:90px;"></textarea></div>
|
|
|
|
<div class="sm-field" id="mr-dec-wrap">
|
|
<label>Decisioni e azioni (elementi in uscita §9.3.3)</label>
|
|
<div id="mr-decisions"></div>
|
|
<button class="btn btn-outline btn-sm" type="button" id="mr-dec-add" onclick="decAdd()">+ Aggiungi decisione</button>
|
|
</div>
|
|
|
|
<div class="sm-err" id="mr-err" role="alert"></div>
|
|
</div>
|
|
<div class="sm-foot">
|
|
<a class="btn btn-outline hidden" id="mr-report-link" href="#" target="_blank" rel="noopener">Verbale (stampabile)</a>
|
|
<button class="btn btn-outline hidden" id="mr-approve" type="button" onclick="mrApprove()">Approva e congela</button>
|
|
<button class="btn btn-outline" type="button" onclick="mrClose()">Chiudi</button>
|
|
<button class="btn btn-primary" type="button" id="mr-save" onclick="mrSave()">Salva</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/js/api.js?v=20260625d"></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=20260625d"></script>
|
|
<script src="/js/i18n.js?v=20260631"></script>
|
|
<script src="/js/help.js?v=20260625d"></script>
|
|
<script>
|
|
'use strict';
|
|
/*
|
|
* NIS2 Agile - Riesame di Direzione (ISO 27001 §9.3) — Modulo B
|
|
* Lista riesami + form verbale: input aggregati (gather), decisioni, draft→approvato, report.
|
|
*/
|
|
let MR = { reviews: [], members: [], roles: [], detail: null, gather: null, decisions: [] };
|
|
const ST_LABELS = { draft: 'Bozza', approved: 'Approvato' };
|
|
const DEC_LABELS = { open: 'Aperta', in_progress: 'In corso', done: 'Conclusa' };
|
|
const SEC_TITLES = {
|
|
corrective_actions: 'Azioni dal riesame precedente / CAPA aperte',
|
|
stakeholders: 'Contesto e parti interessate',
|
|
internal_audits: 'Risultati audit interni',
|
|
nonconformities: 'Non conformità e azioni correttive',
|
|
compliance_score: 'Monitoraggio (avanzamento SoA / KPI)',
|
|
objectives: 'Obiettivi del SGSI',
|
|
risks: 'Rischi e stato del trattamento',
|
|
normative: 'Aggiornamenti normativi non riscontrati',
|
|
training: 'Formazione e consapevolezza',
|
|
upcoming_deadlines: 'Scadenze imminenti (90 gg)'
|
|
};
|
|
|
|
function el(id) { return document.getElementById(id); }
|
|
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
|
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
|
|
|
|
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', e => { if (e.key === 'Escape') mrClose(); });
|
|
const ov = el('mr-modal'); if (ov) ov.addEventListener('click', e => { if (e.target === ov) mrClose(); });
|
|
await mrLoadAll();
|
|
});
|
|
|
|
async function mrLoadAll() {
|
|
el('mr-list').innerHTML = '<div class="mr-empty">Caricamento…</div>';
|
|
try {
|
|
const [revs, members, roles] = await Promise.all([
|
|
api.mgmtReviewList(),
|
|
safe(() => api.orgMembers ? api.orgMembers() : null),
|
|
safe(() => api.orgRolesList())
|
|
]);
|
|
MR.reviews = (revs && revs.reviews) || [];
|
|
MR.members = membersFrom(members);
|
|
MR.roles = (roles && roles.roles) || (roles && roles.org_roles) || [];
|
|
renderList();
|
|
} catch (e) {
|
|
el('mr-list').innerHTML = '<div class="mr-empty">Errore: ' + esc(e.message || e) + '</div>';
|
|
}
|
|
}
|
|
async function safe(fn) { try { return await fn(); } catch (e) { return null; } }
|
|
function membersFrom(m) {
|
|
if (!m) return [];
|
|
const arr = m.members || m.users || m.items || (Array.isArray(m) ? m : []);
|
|
return (arr || []).map(u => ({ id: u.user_id || u.id, name: u.full_name || u.name || u.email || ('Utente ' + (u.user_id || u.id)) }));
|
|
}
|
|
|
|
function renderList() {
|
|
if (!MR.reviews.length) { el('mr-list').innerHTML = '<div class="mr-empty">Nessun riesame. Creane uno con "+ Nuovo riesame".</div>'; return; }
|
|
const rows = MR.reviews.map(r =>
|
|
'<tr class="click" onclick="mrOpen(' + r.id + ')">'
|
|
+ '<td><strong>' + esc(r.code || '—') + '</strong></td>'
|
|
+ '<td>' + esc(r.review_date || '—') + '</td>'
|
|
+ '<td>' + esc(r.period_label || '—') + '</td>'
|
|
+ '<td>' + esc(r.chair_name || '—') + '</td>'
|
|
+ '<td>' + r.n_done + '/' + r.n_decisions + '</td>'
|
|
+ '<td><span class="mr-badge s-' + r.status + '">' + (ST_LABELS[r.status] || r.status) + '</span></td>'
|
|
+ '</tr>'
|
|
).join('');
|
|
el('mr-list').innerHTML = '<table class="mr-table"><thead><tr>'
|
|
+ '<th>Codice</th><th>Data</th><th>Periodo</th><th>Presidente</th><th>Decisioni</th><th>Stato</th>'
|
|
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
|
|
}
|
|
|
|
function fillChair(selectedId) {
|
|
el('mr-chair').innerHTML = '<option value="">— Nessuno —</option>'
|
|
+ MR.members.map(u => '<option value="' + u.id + '"' + (String(u.id) === String(selectedId) ? ' selected' : '') + '>' + esc(u.name) + '</option>').join('');
|
|
}
|
|
function roleOptions(selectedId) {
|
|
return '<option value="">— Nessun ruolo —</option>'
|
|
+ MR.roles.map(r => '<option value="' + r.id + '"' + (String(r.id) === String(selectedId) ? ' selected' : '') + '>' + esc(r.role_name || r.name) + '</option>').join('');
|
|
}
|
|
|
|
async function mrOpen(id) {
|
|
el('mr-id').value = id || '';
|
|
el('mr-err').textContent = '';
|
|
el('mr-gather-info').textContent = '';
|
|
MR.detail = null; MR.gather = null; MR.decisions = [];
|
|
let r = null;
|
|
if (id) { try { r = await api.mgmtReviewGet(id); MR.detail = r; } catch (e) { el('mr-err').textContent = e.message; } }
|
|
|
|
el('mr-modal-title').textContent = id ? ('Riesame ' + (r ? (r.code || '') : '')) : 'Nuovo riesame';
|
|
el('mr-date').value = r ? (r.review_date || '') : '';
|
|
el('mr-period').value = r ? (r.period_label || '') : '';
|
|
fillChair(r ? r.chair_user_id : '');
|
|
el('mr-attendees').value = r && r.attendees ? r.attendees.map(a => a.name + (a.role ? ' — ' + a.role : '')).join('\n') : '';
|
|
el('mr-conclusions').value = r ? (r.conclusions || '') : '';
|
|
|
|
MR.gather = r && r.snapshot ? r.snapshot : null;
|
|
renderInputs();
|
|
|
|
MR.decisions = r && r.decisions ? r.decisions.slice() : [];
|
|
renderDecisions();
|
|
|
|
const approved = r && r.status === 'approved';
|
|
const fields = ['mr-date','mr-period','mr-chair','mr-attendees','mr-conclusions'];
|
|
fields.forEach(f => { el(f).disabled = !!approved; });
|
|
el('mr-save').classList.toggle('hidden', !!approved);
|
|
el('mr-gather-btn').classList.toggle('hidden', !!approved);
|
|
el('mr-dec-add').classList.toggle('hidden', !!approved);
|
|
el('mr-approve').classList.toggle('hidden', !id || !!approved);
|
|
const rl = el('mr-report-link');
|
|
if (id) {
|
|
const orgQ = api && api.orgId ? ('&org_id=' + encodeURIComponent(api.orgId)) : '';
|
|
rl.href = (api.baseUrl || '/api') + '/management-reviews/' + id + '/report?token=' + encodeURIComponent(token()) + orgQ;
|
|
rl.classList.remove('hidden');
|
|
} else { rl.classList.add('hidden'); }
|
|
|
|
el('mr-modal').classList.add('open');
|
|
setTimeout(function () { var fe = el('mr-modal').querySelector('input,select,textarea'); if (fe) fe.focus(); }, 50);
|
|
}
|
|
function mrClose() { el('mr-modal').classList.remove('open'); }
|
|
function token() { try { return (api && api.token) || localStorage.getItem('nis2_access_token') || ''; } catch (e) { return ''; } }
|
|
|
|
function renderInputs() {
|
|
const sections = (MR.gather && MR.gather.sections) || null;
|
|
if (!sections) { el('mr-inputs').innerHTML = '<div class="mr-empty">Nessun dato aggregato. Premi "Aggrega dati".</div>'; return; }
|
|
let html = '';
|
|
Object.keys(SEC_TITLES).forEach(key => {
|
|
const sec = sections[key];
|
|
const na = !sec || !sec.available;
|
|
html += '<div class="mr-section' + (na ? ' na' : '') + '"><h4>' + esc(SEC_TITLES[key]) + (na ? '<span class="mr-pill">n/d</span>' : '') + '</h4>';
|
|
html += '<div class="body">';
|
|
if (na) { html += 'Dato non disponibile.'; }
|
|
else {
|
|
html += esc(summarize(key, sec));
|
|
const items = sec.items || [];
|
|
if (items.length) {
|
|
html += '<ul>' + items.slice(0, 8).map(it => {
|
|
const label = it.title || it.code || '';
|
|
const extra = ['code','status','severity','due_date','next_review_date','impact','reference']
|
|
.filter(f => it[f] && it[f] !== label).map(f => esc(it[f])).join(' · ');
|
|
return '<li>' + esc(label) + (extra ? ' <span class="hint">(' + extra + ')</span>' : '') + '</li>';
|
|
}).join('') + (items.length > 8 ? '<li class="hint">… e altri ' + (items.length - 8) + '</li>' : '') + '</ul>';
|
|
}
|
|
}
|
|
html += '</div></div>';
|
|
});
|
|
el('mr-inputs').innerHTML = html;
|
|
}
|
|
function summarize(key, sec) {
|
|
switch (key) {
|
|
case 'nonconformities': return (sec.open_count || 0) + ' non conformità aperte.';
|
|
case 'corrective_actions': return (sec.open_count || 0) + ' azioni correttive in corso.';
|
|
case 'internal_audits': return (sec.total || 0) + ' audit interni (' + (sec.completed || 0) + ' completati).';
|
|
case 'risks': { const t = sec.treatments || {}; return (sec.total || 0) + ' rischi; trattamenti ' + (t.completed || 0) + ' completati, ' + (t.overdue || 0) + ' in ritardo.'; }
|
|
case 'compliance_score': return 'Avanzamento medio SoA: ' + (sec.soa_avg_implementation != null ? sec.soa_avg_implementation + '%' : 'n/d') + '.';
|
|
case 'objectives': return (sec.count || 0) + ' obiettivi SGSI.';
|
|
case 'training': return (sec.completed || 0) + '/' + (sec.total || 0) + ' completate, ' + (sec.overdue || 0) + ' in ritardo.';
|
|
case 'stakeholders': return (sec.total || 0) + ' attività stakeholder (' + (sec.completed || 0) + ' completate).';
|
|
case 'normative': return (sec.pending_count || 0) + ' aggiornamenti normativi da riscontrare.';
|
|
case 'upcoming_deadlines': return (sec.count || 0) + ' scadenze nei prossimi 90 giorni.';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
async function mrGather() {
|
|
const btn = el('mr-gather-btn'); btn.disabled = true;
|
|
el('mr-gather-info').textContent = 'Aggregazione in corso…';
|
|
try {
|
|
MR.gather = await api.mgmtReviewGather();
|
|
renderInputs();
|
|
el('mr-gather-info').textContent = 'Dati aggregati: ' + ((MR.gather && MR.gather.generated_at) || '');
|
|
} catch (e) {
|
|
el('mr-gather-info').textContent = 'Errore: ' + (e.message || e);
|
|
} finally { btn.disabled = false; }
|
|
}
|
|
|
|
// ── Decisioni ──
|
|
function renderDecisions() {
|
|
const wrap = el('mr-decisions');
|
|
const approved = MR.detail && MR.detail.status === 'approved';
|
|
if (!MR.decisions.length) { wrap.innerHTML = '<div class="mr-empty">Nessuna decisione. Aggiungine una.</div>'; return; }
|
|
wrap.innerHTML = MR.decisions.map((d, i) => {
|
|
if (approved) {
|
|
return '<div class="dec-row"><div class="grow">' + esc(d.decision) + '</div>'
|
|
+ '<span class="dec-stat dec-' + d.status + '">' + (DEC_LABELS[d.status] || d.status) + '</span>'
|
|
+ (d.owner_role_name ? '<span class="hint">' + esc(d.owner_role_name) + '</span>' : '')
|
|
+ (d.due_date ? '<span class="hint">' + esc(d.due_date) + '</span>' : '')
|
|
+ (d.capa_code ? '<span class="mr-pill">' + esc(d.capa_code) + '</span>' : '') + '</div>';
|
|
}
|
|
return '<div class="dec-row" data-i="' + i + '">'
|
|
+ '<input type="text" class="grow" value="' + escAttr(d.decision) + '" oninput="decEdit(' + i + ',\'decision\',this.value)" placeholder="Decisione…">'
|
|
+ '<select onchange="decEdit(' + i + ',\'owner_role_id\',this.value)">' + roleOptions(d.owner_role_id) + '</select>'
|
|
+ '<input type="date" value="' + escAttr(d.due_date || '') + '" onchange="decEdit(' + i + ',\'due_date\',this.value)">'
|
|
+ '<select onchange="decEdit(' + i + ',\'status\',this.value)">'
|
|
+ ['open','in_progress','done'].map(s => '<option value="' + s + '"' + (d.status === s ? ' selected' : '') + '>' + DEC_LABELS[s] + '</option>').join('')
|
|
+ '</select>'
|
|
+ '<button class="dec-del" type="button" onclick="decDel(' + i + ')" aria-label="Rimuovi">×</button>'
|
|
+ '</div>';
|
|
}).join('');
|
|
}
|
|
function decAdd() {
|
|
if (!el('mr-id').value) { el('mr-err').textContent = 'Salva prima il riesame, poi aggiungi le decisioni.'; return; }
|
|
MR.decisions.push({ id: null, decision: '', owner_role_id: null, due_date: '', status: 'open', capa_code: null, _new: true });
|
|
renderDecisions();
|
|
}
|
|
function decEdit(i, field, val) { if (MR.decisions[i]) { MR.decisions[i][field] = val; MR.decisions[i]._dirty = true; } }
|
|
function decDel(i) {
|
|
const d = MR.decisions[i]; if (!d) return;
|
|
// Niente endpoint di cancellazione lato server: si rimuovono solo le decisioni NON ancora salvate.
|
|
if (d.id) { el('mr-err').textContent = 'Una decisione salvata non può essere cancellata: impostala su "Conclusa".'; return; }
|
|
MR.decisions.splice(i, 1); renderDecisions();
|
|
}
|
|
|
|
async function mrSave() {
|
|
const id = el('mr-id').value;
|
|
const payload = {
|
|
review_date: el('mr-date').value || null,
|
|
period_label: el('mr-period').value.trim() || null,
|
|
chair_user_id: el('mr-chair').value ? parseInt(el('mr-chair').value, 10) : null,
|
|
attendees: parseAttendees(el('mr-attendees').value),
|
|
conclusions: el('mr-conclusions').value.trim() || null
|
|
};
|
|
if (MR.gather) payload.snapshot = MR.gather;
|
|
const btn = el('mr-save'); btn.disabled = true;
|
|
try {
|
|
let reviewId;
|
|
if (id) { await api.mgmtReviewUpdate(parseInt(id, 10), payload); reviewId = parseInt(id, 10); }
|
|
else { const r = await api.mgmtReviewCreate(payload); reviewId = r.id; el('mr-id').value = reviewId; }
|
|
await syncDecisions(reviewId);
|
|
showNotification(id ? 'Riesame aggiornato.' : 'Riesame creato.', 'success');
|
|
await mrLoadAll();
|
|
await mrOpen(reviewId);
|
|
} catch (e) {
|
|
el('mr-err').textContent = e.message || String(e);
|
|
} finally { btn.disabled = false; }
|
|
}
|
|
|
|
async function syncDecisions(reviewId) {
|
|
for (const d of MR.decisions) {
|
|
const body = { decision: (d.decision || '').trim(), owner_role_id: d.owner_role_id ? parseInt(d.owner_role_id, 10) : null, due_date: d.due_date || null, status: d.status || 'open' };
|
|
if (!body.decision) continue;
|
|
if (d.id) { if (d._dirty) await api.mgmtReviewUpdateDecision(d.id, body); }
|
|
else { await api.mgmtReviewAddDecision(reviewId, body); }
|
|
}
|
|
}
|
|
|
|
function parseAttendees(text) {
|
|
return (text || '').split('\n').map(l => l.trim()).filter(Boolean).map(l => {
|
|
const parts = l.split('—');
|
|
if (parts.length >= 2) return { name: parts[0].trim(), role: parts.slice(1).join('—').trim() };
|
|
const d = l.split(' - ');
|
|
if (d.length >= 2) return { name: d[0].trim(), role: d.slice(1).join(' - ').trim() };
|
|
return { name: l };
|
|
});
|
|
}
|
|
|
|
async function mrApprove() {
|
|
const id = el('mr-id').value; if (!id) return;
|
|
if (!confirm('Approvare e congelare il verbale? Dopo l\'approvazione non sarà più modificabile.')) return;
|
|
const btn = el('mr-approve'); btn.disabled = true;
|
|
try {
|
|
// salva eventuali modifiche pendenti prima del congelamento
|
|
await mrSilentSave(parseInt(id, 10));
|
|
await api.mgmtReviewApprove(parseInt(id, 10));
|
|
showNotification('Riesame approvato e congelato.', 'success');
|
|
await mrLoadAll();
|
|
await mrOpen(parseInt(id, 10));
|
|
} catch (e) {
|
|
el('mr-err').textContent = e.message || String(e);
|
|
} finally { btn.disabled = false; }
|
|
}
|
|
async function mrSilentSave(reviewId) {
|
|
const payload = {
|
|
review_date: el('mr-date').value || null,
|
|
period_label: el('mr-period').value.trim() || null,
|
|
chair_user_id: el('mr-chair').value ? parseInt(el('mr-chair').value, 10) : null,
|
|
attendees: parseAttendees(el('mr-attendees').value),
|
|
conclusions: el('mr-conclusions').value.trim() || null
|
|
};
|
|
if (MR.gather) payload.snapshot = MR.gather;
|
|
await api.mgmtReviewUpdate(reviewId, payload);
|
|
await syncDecisions(reviewId);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|