/* ═══════════════════════════════════════════════════════════════════════════ * dashboard.js — Dashboard V3 (cablata sui DATI REALI via api.js). * ─────────────────────────────────────────────────────────────────────────── * Markup/classi FEDELI al mockup "docs/mockup Ui/index.html" (.daystrip/.weekstrip/ * .stat/.sca/.act/.anag/.doc), ma ZERO dati finti: tutto da * /dashboard/overview · /dashboard/compliance-score · /dashboard/upcoming-deadlines * /dashboard/recent-activity · /organizations/current * Stati loading/empty/error gestiti. NESSUN endpoint nuovo. * I monitor giornaliero/settimanale usano gli EVENTI reali (scadenze + attività); * "Documenti istituzionali" non ha endpoint → stato vuoto onesto (no invenzione). * ═══════════════════════════════════════════════════════════════════════════ */ (function () { 'use strict'; // ── Date helpers (porting dal mockup, ma TODAY = data reale) ──────────── var TODAY = new Date(); TODAY.setHours(0, 0, 0, 0); var MO = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic']; var WDa = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom']; var WDi = ['L', 'M', 'M', 'G', 'V', 'S', 'D']; var DAY = 86400000; function mi(d) { return (d.getDay() + 6) % 7; } function addD(d, n) { return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); } function iso(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } function monday(d) { return addD(d, -mi(d)); } function same(a, b) { return iso(a) === iso(b); } function weekLabel(m) { var s = addD(m, 6); return m.getMonth() === s.getMonth() ? (m.getDate() + ' – ' + s.getDate() + ' ' + MO[s.getMonth()]) : (m.getDate() + ' ' + MO[m.getMonth()] + ' – ' + s.getDate() + ' ' + MO[s.getMonth()]); } function esc(s) { return (typeof escapeHtml === 'function') ? escapeHtml(s) : String(s == null ? '' : s); } // ── EVENTI reali: dizionario iso-date → [{t, c, time}] ────────────────── // Popolato da scadenze (api.getUpcomingDeadlines) + attività (recent-activity). var EVENTS = {}; function pushEvent(dateVal, title, color, time) { if (!dateVal) return; var d = new Date(dateVal); if (isNaN(d.getTime())) return; var k = iso(d); (EVENTS[k] = EVENTS[k] || []).push({ t: title || 'Evento', c: color || 'blue', time: time || '' }); } function evOf(d) { return EVENTS[iso(d)] || []; } function dots(list) { return list.slice(0, 4).map(function (e) { return ''; }).join(''); } // ── Popover (porting fedele dal mockup) ───────────────────────────────── var POP = document.createElement('div'); POP.className = 'cal-pop'; document.body.appendChild(POP); var popTimer; function showPop(anchor, html) { clearTimeout(popTimer); POP.innerHTML = html; POP.style.display = 'block'; var r = anchor.getBoundingClientRect(); var pw = POP.offsetWidth, ph = POP.offsetHeight; var left = r.left + r.width / 2 - pw / 2; left = Math.max(10, Math.min(left, window.innerWidth - pw - 10)); var top = r.top - ph - 10; if (top < 10) top = r.bottom + 10; POP.style.left = left + 'px'; POP.style.top = top + 'px'; requestAnimationFrame(function () { POP.classList.add('show'); }); } function hidePop() { POP.classList.remove('show'); popTimer = setTimeout(function () { POP.style.display = 'none'; }, 150); } function dayPop(d) { var list = evOf(d); var head = WDa[mi(d)] + ' ' + d.getDate() + ' ' + MO[d.getMonth()]; var body = list.length ? list.map(function (e) { return '
' + (e.time ? '' + esc(e.time) + '' : '') + '' + esc(e.t) + '
'; }).join('') : '
Nessun appuntamento.
'; return '

' + head + '

' + body; } function weekPop(m) { var items = []; for (var i = 0; i < 7; i++) { var d = addD(m, i); evOf(d).forEach(function (e) { items.push({ d: d, e: e }); }); } var body = items.length ? items.slice(0, 6).map(function (o) { return '
' + WDa[mi(o.d)] + ' ' + o.d.getDate() + '' + esc(o.e.t) + '
'; }).join('') : '
Nessun appuntamento.
'; if (items.length > 6) body += '
+' + (items.length - 6) + ' altri…
'; return '

' + weekLabel(m) + '

' + body; } function openCal() { window.location.href = 'calendario.html'; } var dayOffset = 0, weekOffset = 0; function renderDaily() { var center = addD(TODAY, dayOffset); var wrap = document.getElementById('daystrip'); if (!wrap) return; wrap.innerHTML = ''; for (var i = -6; i <= 6; i++) { (function (d) { var cell = document.createElement('div'); cell.className = 'daycell' + (mi(d) >= 5 ? ' weekend' : '') + (same(d, TODAY) ? ' today' : ''); cell.innerHTML = '
' + WDa[mi(d)] + '
' + d.getDate() + '
' + MO[d.getMonth()] + '
' + dots(evOf(d)) + '
'; cell.addEventListener('mouseenter', function () { showPop(cell, dayPop(d)); }); cell.addEventListener('mouseleave', hidePop); cell.addEventListener('click', openCal); wrap.appendChild(cell); })(addD(center, i)); } } function renderWeekly() { var curMon = monday(TODAY); var centerMon = addD(curMon, weekOffset * 7); var wrap = document.getElementById('weekstrip'); if (!wrap) return; wrap.innerHTML = ''; for (var w = -1; w <= 1; w++) { (function (m) { var isCur = same(m, curMon); var diff = Math.round((m - curMon) / (7 * DAY)); var tag = diff === 0 ? 'Settimana in corso' : diff === -1 ? 'Settimana precedente' : diff === 1 ? 'Settimana successiva' : diff < 0 ? (-diff) + ' settimane fa' : 'Tra ' + diff + ' settimane'; var block = document.createElement('div'); block.className = 'weekblock' + (isCur ? ' current' : ''); var minis = ''; for (var i = 0; i < 7; i++) { var d = addD(m, i); minis += '
' + WDi[mi(d)] + '
' + d.getDate() + '
' + dots(evOf(d)) + '
'; } block.innerHTML = '
' + weekLabel(m) + '' + tag + '
' + minis + '
'; block.addEventListener('mouseenter', function () { showPop(block, weekPop(m)); }); block.addEventListener('mouseleave', hidePop); block.addEventListener('click', openCal); wrap.appendChild(block); })(addD(centerMon, w * 7)); } } function wireCalControls() { var map = [['d-prev', function () { dayOffset--; renderDaily(); }], ['d-next', function () { dayOffset++; renderDaily(); }], ['d-today', function () { dayOffset = 0; renderDaily(); }], ['w-prev', function () { weekOffset--; renderWeekly(); }], ['w-next', function () { weekOffset++; renderWeekly(); }], ['w-today', function () { weekOffset = 0; renderWeekly(); }]]; map.forEach(function (p) { var el = document.getElementById(p[0]); if (el) el.onclick = p[1]; }); } // ── Stato cella generico (loading/empty/error) ────────────────────────── function setLoading(id) { var el = document.getElementById(id); if (el) el.innerHTML = '
Caricamento…
'; } function setEmpty(id, title, sub, icon) { var el = document.getElementById(id); if (el) el.innerHTML = '
' + esc(title) + '' + esc(sub || '') + '
'; refreshIcons(); } function setError(id, msg) { var el = document.getElementById(id); if (el) el.innerHTML = '
Errore di caricamento' + esc(msg || 'Riprova tra poco.') + '
'; refreshIcons(); } function refreshIcons() { if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} } } // ── Mappa categoria → colore badge (coerente col mockup) ──────────────── function catColor(cat) { var c = String(cat || '').toLowerCase(); if (/incid/.test(c)) return 'red'; if (/formaz|training/.test(c)) return 'green'; if (/govern|riesame|board|direzion/.test(c)) return 'blue'; if (/control|rischi|risk|audit/.test(c)) return 'amber'; return 'gray'; } // ── 1) Stat cards (3) — dati reali da overview + compliance-score ─────── async function loadStats() { var elGap = document.getElementById('stat-gap'); var elTrain = document.getElementById('stat-training'); var elRisk = document.getElementById('stat-risk'); try { var ov = await api.getDashboardOverview(); var d = (ov && ov.success && ov.data) ? ov.data : {}; // Formazione: % completata (overview.training {total, completed}) var tTot = d.training ? (parseInt(d.training.total) || 0) : 0; var tDone = d.training ? (parseInt(d.training.completed) || 0) : 0; var tPct = tTot > 0 ? Math.round((tDone / tTot) * 100) : 0; if (elTrain) { elTrain.querySelector('.val').textContent = tTot > 0 ? (tPct + '%') : '0%'; elTrain.querySelector('.sub').textContent = tTot > 0 ? (tDone + ' di ' + tTot + ' completati') : 'Nessun corso assegnato'; } // Rischi valutati: total rischi (overview.risks.total) + % implementazione misure var riskTotal = d.risks ? (parseInt(d.risks.total) || 0) : 0; // compliance-score per la percentuale "valutati/implementati" var pct = 0; try { var sc = await api.getComplianceScore(); if (sc && sc.success && sc.data) { pct = (sc.data.avg_implementation && sc.data.avg_implementation > 0) ? Math.round(sc.data.avg_implementation) : Math.round(sc.data.score || 0); } } catch (e) {} if (elRisk) { elRisk.querySelector('.val').textContent = pct + '%'; elRisk.querySelector('.sub').textContent = riskTotal > 0 ? ('su ' + riskTotal + ' del registro rischi') : 'Registro rischi vuoto'; var barI = elRisk.querySelector('.bar > i'); if (barI) barI.style.width = Math.max(0, Math.min(100, pct)) + '%'; } // GAP analysis: numero assessment in corso (overview non sempre lo espone → usa list) if (elGap) { fillGapCard(elGap); } } catch (e) { if (elGap) { elGap.querySelector('.val').textContent = '—'; elGap.querySelector('.sub').textContent = 'Dati non disponibili'; } if (elTrain) { elTrain.querySelector('.val').textContent = '—'; elTrain.querySelector('.sub').textContent = 'Dati non disponibili'; } if (elRisk) { elRisk.querySelector('.val').textContent = '—'; elRisk.querySelector('.sub').textContent = 'Dati non disponibili'; } } } async function fillGapCard(elGap) { try { var r = await api.listAssessments(); var list = (r && r.success && Array.isArray(r.data)) ? r.data : (r && Array.isArray(r.data && r.data.items) ? r.data.items : []); if (!Array.isArray(list)) list = []; var inProgress = list.filter(function (a) { var s = String(a.status || '').toLowerCase(); return s && s !== 'completed' && s !== 'completato'; }).length; elGap.querySelector('.val').textContent = list.length ? String(inProgress) : '0'; elGap.querySelector('.sub').textContent = list.length ? ('su ' + list.length + ' assessment') : 'Nessun assessment'; } catch (e) { elGap.querySelector('.val').textContent = '—'; elGap.querySelector('.sub').textContent = 'Dati non disponibili'; } } // ── 2) Scadenze (prossimi 10 giorni) — dati reali ─────────────────────── async function loadDeadlines() { setLoading('sca-list'); try { var r = await api.getUpcomingDeadlines(); var list = (r && r.success && Array.isArray(r.data)) ? r.data : []; // alimenta i monitor calendario con eventi reali list.forEach(function (dl) { var when = dl.due_date || dl.date || dl.deadline; pushEvent(when, dl.title || dl.description || 'Scadenza', catColor(dl.category || dl.type), ''); }); renderDaily(); renderWeekly(); var hint = document.getElementById('sca-hint'); if (hint) hint.textContent = list.length + (list.length === 1 ? ' in agenda' : ' in agenda'); if (!list.length) { setEmpty('sca-list', 'Nessuna scadenza imminente', 'Le prossime scadenze appariranno qui.', 'calendar-check'); return; } var html = list.slice(0, 10).map(function (dl) { var d = new Date(dl.due_date || dl.date || dl.deadline); var day = isNaN(d.getTime()) ? '—' : d.getDate(); var mon = isNaN(d.getTime()) ? '' : MO[d.getMonth()].charAt(0).toUpperCase() + MO[d.getMonth()].slice(1); var cat = dl.category || dl.type || ''; var who = dl.owner || dl.assignee || dl.responsible || ''; return '
' + day + '' + esc(mon) + '
' + '
' + esc(dl.title || dl.description || 'Scadenza') + '' + '
' + (cat ? '' + esc(cat) + '' : '') + (who ? '' + esc(who) + '' : '') + '
'; }).join(''); document.getElementById('sca-list').innerHTML = html; } catch (e) { setError('sca-list'); } } // ── 3) Attività aperte — dati reali da recent-activity ────────────────── async function loadActivities() { setLoading('act-scroll'); try { var r = await api.getRecentActivity(); var list = (r && r.success && Array.isArray(r.data)) ? r.data : []; // alimenta i monitor con eventi reali (data attività) list.forEach(function (a) { var when = a.created_at || a.timestamp || a.date; pushEvent(when, a.description || a.message || a.action || 'Attività', catColor(a.category || a.type || a.entity_type), ''); }); renderDaily(); renderWeekly(); if (!list.length) { setEmpty('act-scroll', 'Nessuna attività recente', 'Le attività appariranno qui.', 'activity'); return; } var html = list.slice(0, 30).map(function (a) { var title = a.description || a.message || a.action || 'Attività'; var who = a.user_name || a.actor || a.user || ''; var when = a.created_at || a.timestamp || a.date; var ago = (typeof timeAgo === 'function' && when) ? timeAgo(when) : ''; var st = a.status || a.entity_type || a.type || ''; var stColor = catColor(st); return '
' + esc(title) + '' + '
' + (who ? '' + esc(who) + '' : '') + (ago ? '· ' + esc(ago) + '' : '') + '
' + (st ? '' + esc(st) + '' : '') + '
'; }).join(''); document.getElementById('act-scroll').innerHTML = html; } catch (e) { setError('act-scroll'); } } // ── 4) Anagrafica aziendale — dati reali da organizations/current ─────── var ENTITY_LABEL = { essential: 'Soggetto essenziale', important: 'Soggetto importante', not_applicable: 'Fuori ambito', voluntary: 'Adesione volontaria' }; function initials(name) { return String(name || '').trim().split(/\s+/).map(function (w) { return w[0]; }).filter(Boolean).slice(0, 2).join('').toUpperCase() || '—'; } async function loadAnag() { var card = document.getElementById('anag-card'); if (!card) return; try { var r = await api.getCurrentOrg(); if (!r || !r.success || !r.data) { setEmpty('anag-card', 'Nessuna organizzazione', 'Completa l’onboarding per visualizzare l’anagrafica.', 'building-2'); return; } var o = r.data; var nis = ENTITY_LABEL[o.entity_type] || (o.entity_type ? esc(o.entity_type) : 'Da classificare'); var fields = [ ['Settore', o.sector || o.nis2_sector || '—'], ['Partita IVA', o.vat_number || '—'], ['Sede legale', [o.city, o.country].filter(Boolean).join(', ') || o.legal_address || '—'], ['Dipendenti', (o.employee_count != null && o.employee_count !== '') ? o.employee_count : '—'], ['Codice fiscale', o.fiscal_code || '—'], ['Classificazione NIS2', nis] ]; var grid = fields.map(function (f) { return '
' + esc(f[0]) + '' + esc(f[1]) + '
'; }).join(''); card.innerHTML = '
' + '

' + esc(o.name || 'Organizzazione') + '

' + '' + nis + '
' + '
' + grid + '
'; } catch (e) { setError('anag-card'); } } // ── 5) Documenti istituzionali — /api/institutional-docs (ticket #499) ── // Le 5 voci standard sono SEMPRE presenti (Codice Etico, Mission, Vision, // Statuto, Piano Sanzionatorio); ogni documento richiede una descrizione. function canEditDocs() { var r = (localStorage.getItem('nis2_user_role') || '').toLowerCase(); return r === 'org_admin' || r === 'compliance_manager' || r === 'super_admin'; } function docRow(item, stdLabel) { var filled = item && item.description && String(item.description).trim() !== ''; var title = esc((item && item.title) || stdLabel || 'Documento'); var editBtn = canEditDocs() ? '' : ''; var delBtn = (canEditDocs() && item && item.id && Number(item.is_standard) !== 1) ? '' : ''; var body = filled ? '' + esc(item.description) + '' : 'Nessuna descrizione — ' + (canEditDocs() ? 'compila la voce.' : 'da compilare.') + ''; var fileLink = (item && item.file_url) ? ' documento' : ''; return '
' + '
' + title + fileLink + '' + body + '
' + '
' + editBtn + delBtn + '
'; } function stdKeyOf(label, standards) { for (var i = 0; i < standards.length; i++) if (standards[i].label === label) return standards[i].key; return null; } // Le 5 voci standard DEVONO essere sempre presenti anche se l'API non risponde. var STD_FALLBACK = [ { key: 'codice_etico', label: 'Codice Etico' }, { key: 'mission', label: 'Mission' }, { key: 'vision', label: 'Vision' }, { key: 'statuto', label: 'Statuto' }, { key: 'piano_sanzionatorio', label: 'Piano Sanzionatorio' } ]; function renderDocsCard(docs, standards) { var card = document.getElementById('doc-card'); if (!card) return; standards = (standards && standards.length) ? standards : STD_FALLBACK; docs = docs || []; var byKey = {}; var customs = []; docs.forEach(function (d) { if (d.doc_key) byKey[d.doc_key] = d; else customs.push(d); }); var html = ''; standards.forEach(function (s) { var item = byKey[s.key] || { doc_key: s.key, title: s.label, is_standard: 1 }; html += docRow(item, s.label); }); customs.forEach(function (d) { html += docRow(d, null); }); if (canEditDocs()) { html += ''; } card.innerHTML = '
' + html + '
'; refreshIcons(); wireDocEvents(card, standards); } function loadDocs() { var card = document.getElementById('doc-card'); if (!card) return; setLoading('doc-card'); api.request('GET', '/institutional-docs/list').then(function (res) { var data = (res && res.data) || res || {}; renderDocsCard(data.docs || [], data.standards); }).catch(function () { // Anche in caso di errore mostriamo comunque le 5 voci standard. renderDocsCard([], STD_FALLBACK); }); } function docModal(opts, standards) { var isStd = !!opts.doc_key; var titleField = isStd ? '' : ''; var body = '
' + titleField + '
' + '
' + '
' + '
' + '
'; var footer = '' + ''; showModal(opts.id ? 'Modifica documento' : (isStd ? 'Compila: ' + (opts.title || '') : 'Aggiungi documento'), body, { footer: footer }); var saveBtn = document.getElementById('doc-f-save'); saveBtn.addEventListener('click', function () { var title = document.getElementById('doc-f-title').value.trim(); var desc = document.getElementById('doc-f-desc').value.trim(); var file = document.getElementById('doc-f-file').value.trim(); if (!title) { showNotification('Il titolo è obbligatorio', 'error'); return; } if (!desc) { showNotification('La descrizione è obbligatoria', 'error'); return; } saveBtn.disabled = true; api.request('POST', '/institutional-docs/save', { id: opts.id || undefined, doc_key: opts.doc_key || undefined, title: title, description: desc, file_url: file || undefined }).then(function () { closeModal(); showNotification('Documento salvato', 'success'); loadDocs(); }).catch(function (e) { saveBtn.disabled = false; showNotification((e && e.message) || 'Salvataggio non riuscito', 'error'); }); }); } function wireDocEvents(card, standards) { card.querySelectorAll('.doc-edit').forEach(function (btn) { btn.addEventListener('click', function () { var el = btn.closest('.doc-item'); docModal({ id: el.getAttribute('data-id') || null, doc_key: el.getAttribute('data-key') || null, title: el.querySelector('b') ? el.querySelector('b').childNodes[0].textContent.trim() : '', description: el.querySelector('.doc-empty-desc') ? '' : (el.querySelector('.doc-desc') ? el.querySelector('.doc-desc').textContent : ''), file_url: el.querySelector('.doc-file') ? el.querySelector('.doc-file').getAttribute('href') : '' }, standards); }); }); card.querySelectorAll('.doc-del').forEach(function (btn) { btn.addEventListener('click', function () { var el = btn.closest('.doc-item'); var id = el.getAttribute('data-id'); if (!id || !confirm('Eliminare questa voce?')) return; api.request('DELETE', '/institutional-docs/' + id).then(function () { showNotification('Documento eliminato', 'success'); loadDocs(); }).catch(function (e) { showNotification((e && e.message) || 'Eliminazione non riuscita', 'error'); }); }); }); var addBtn = card.querySelector('.doc-add'); if (addBtn) addBtn.addEventListener('click', function () { docModal({}, standards); }); } // ── Init pubblico (chiamato dall'init pagina dopo checkAuth/loadSidebar) ─ function initDashboard() { wireCalControls(); renderDaily(); renderWeekly(); // render iniziale (eventi reali arriveranno e ri-renderizzano) loadStats(); loadDeadlines(); loadActivities(); loadAnag(); loadDocs(); } window.initDashboard = initDashboard; })();