[FEAT] UI V3 — foundation (v3.css + topnav-v3.js + Inter/Lucide self-host) + dashboard pilota fedele al mockup
Fase 0 della migrazione UI V3 (sidebar V2 → top-nav navy/oro, font Inter, icone Lucide). - public/css/v3.css: design system portato fedelmente dal mockup index.html (token :root, chrome appbar+mega-menu, card/stat/calendario/anag/doc/fab, @media ≤900/≤560), + aggiunte minime V3+ per stati loading/empty/error, mobile nav toggle e touch≥44px (AGID/WCAG). - public/vendor/inter/: Inter self-host (variable woff2 latin+latin-ext) + inter.css + README (NO Google CDN). - public/vendor/lucide/lucide.min.js: Lucide UMD self-host (NO unpkg CDN). - public/js/topnav-v3.js: appbar + mega-menu data-driven dalla mappa IA (§6, 9 gruppi), aria-current sulla voce attiva, riusa gli helper di common.js (utente/logout/versione/org-switcher, branding, impersonate) e i 3 FAB reali (ARIA + bug-reporter + notifiche). window.loadSidebar=loadTopnavV3. - public/dashboard.html: convertita al V3 fedele al mockup, cablata sui DATI REALI via api.js (overview/compliance-score/upcoming-deadlines/recent-activity/organizations-current); monitor giornaliero/settimanale alimentati dagli eventi reali; documenti istituzionali = stato vuoto onesto. - public/js/dashboard.js (nuovo): wiring dati reali + render calendario/scadenze/attività/anagrafica; stati loading/empty/error; zero dati finti del mockup. node --check OK su topnav-v3.js e dashboard.js. Solo UI: nessun PHP/DB/api.js toccato. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2a5d90f8bb
commit
b0fc190704
@@ -0,0 +1,311 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* 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 '<i class="ev ' + e.c + '"></i>'; }).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 '<div class="pe"><span class="ev ' + e.c + '"></span><div>' + (e.time ? '<span class="tt">' + esc(e.time) + '</span>' : '') + '<b>' + esc(e.t) + '</b></div></div>'; }).join('')
|
||||
: '<div class="empty">Nessun appuntamento.</div>';
|
||||
return '<h4>' + head + '</h4>' + 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 '<div class="pe"><span class="ev ' + o.e.c + '"></span><div><span class="tt">' + WDa[mi(o.d)] + ' ' + o.d.getDate() + '</span><b>' + esc(o.e.t) + '</b></div></div>'; }).join('')
|
||||
: '<div class="empty">Nessun appuntamento.</div>';
|
||||
if (items.length > 6) body += '<div class="empty" style="margin-top:6px">+' + (items.length - 6) + ' altri…</div>';
|
||||
return '<h4>' + weekLabel(m) + '</h4>' + 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 = '<div class="wd">' + WDa[mi(d)] + '</div><div class="dn">' + d.getDate() + '</div><div class="mo">' + MO[d.getMonth()] + '</div><div class="dots">' + dots(evOf(d)) + '</div>';
|
||||
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 += '<div class="mini' + (mi(d) >= 5 ? ' weekend' : '') + (same(d, TODAY) ? ' today' : '') + '"><div class="mi-wd">' + WDi[mi(d)] + '</div><div class="mi-dn">' + d.getDate() + '</div><div class="mi-ev">' + dots(evOf(d)) + '</div></div>'; }
|
||||
block.innerHTML = '<div class="wl"><b>' + weekLabel(m) + '</b><span>' + tag + '</span></div><div class="week7">' + minis + '</div>';
|
||||
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 = '<div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div>'; }
|
||||
function setEmpty(id, title, sub, icon) { var el = document.getElementById(id); if (el) el.innerHTML = '<div class="v3-empty"><i data-lucide="' + (icon || 'inbox') + '" class="ic"></i><b>' + esc(title) + '</b><span>' + esc(sub || '') + '</span></div>'; refreshIcons(); }
|
||||
function setError(id, msg) { var el = document.getElementById(id); if (el) el.innerHTML = '<div class="v3-error"><i data-lucide="alert-triangle" class="ic"></i><b>Errore di caricamento</b><span>' + esc(msg || 'Riprova tra poco.') + '</span></div>'; 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 '<div class="sca"><div class="dpill"><b>' + day + '</b><span>' + esc(mon) + '</span></div>' +
|
||||
'<div class="body"><b>' + esc(dl.title || dl.description || 'Scadenza') + '</b>' +
|
||||
'<div class="meta">' + (cat ? '<span class="badge-t ' + catColor(cat) + '">' + esc(cat) + '</span>' : '') +
|
||||
(who ? '<span class="who">' + esc(who) + '</span>' : '') + '</div></div></div>';
|
||||
}).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 '<div class="act"><div class="body"><b>' + esc(title) + '</b>' +
|
||||
'<div class="meta">' + (who ? '<span class="who">' + esc(who) + '</span>' : '') +
|
||||
(ago ? '<span class="who">· ' + esc(ago) + '</span>' : '') + '</div></div>' +
|
||||
(st ? '<span class="badge-t ' + stColor + '">' + esc(st) + '</span>' : '') + '</div>';
|
||||
}).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 '<div class="f"><span>' + esc(f[0]) + '</span><b>' + esc(f[1]) + '</b></div>'; }).join('');
|
||||
card.innerHTML =
|
||||
'<div class="anag"><div class="anag-logo">' + esc(initials(o.name)) + '</div>' +
|
||||
'<div class="info"><div class="nm"><h3>' + esc(o.name || 'Organizzazione') + '</h3>' +
|
||||
'<span class="badge-nis">' + nis + '</span></div>' +
|
||||
'<div class="anag-grid">' + grid + '</div></div></div>';
|
||||
} catch (e) {
|
||||
setError('anag-card');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5) Documenti istituzionali — nessun endpoint reale → stato vuoto onesto
|
||||
function loadDocs() {
|
||||
var card = document.getElementById('doc-card'); if (!card) return;
|
||||
setEmpty('doc-card', 'Documenti istituzionali', 'Gestisci Mission, Vision ed Etica nelle Impostazioni.', 'file-text');
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
})();
|
||||
@@ -0,0 +1,320 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* topnav-v3.js — Chrome V3 (appbar + mega-menu) data-driven dalla mappa IA (§6).
|
||||
* ───────────────────────────────────────────────────────────────────────────
|
||||
* Sostituisce la sidebar V2 (common-bi.js) con la top-nav del mockup
|
||||
* "docs/mockup Ui/index.html". REGOLE:
|
||||
* - SOLO UI. Riusa gli helper di common.js: _loadUserInfo, _loadVersionFooter,
|
||||
* _loadConsultantOrgSwitcher (via _loadUserInfo), _loadFirmBranding,
|
||||
* _renderImpersonateBanner, switchLang, _getSavedLang, api.logout.
|
||||
* - Stessi ID DOM degli helper V2 (#sidebar-user-name/-avatar/-role,
|
||||
* #sidebar-org-switcher, #sidebar-version, .lang-btn) → gli helper esistenti
|
||||
* funzionano invariati senza toccare api.js.
|
||||
* - Espone window.loadSidebar = loadTopnavV3 → l'init delle pagine resta invariato.
|
||||
* - I 3 FAB reali (campana notifiche + bug-reporter + ARIA) restano quelli di
|
||||
* common.js (qui NON si reimplementano: si applica solo lo stile .fab-stack/.fab
|
||||
* al trittico già montato, per fedeltà al mockup).
|
||||
* - aria-current sulla voce/gruppo attivo (da location.pathname).
|
||||
* - Icone Lucide via <i data-lucide> + lucide.createIcons() (chiamato dall'init pagina).
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Mappa IA (§6 dello spec). 9 gruppi → voci/href reali. ───────────────
|
||||
// top-level: voce diretta (href) oppure gruppo con mega-menu (cols).
|
||||
var NAV_GROUPS = [
|
||||
{ label: 'Dashboard', i18n: 'nav.dashboard', icon: 'layout-dashboard', href: 'dashboard.html' },
|
||||
{ label: 'Calendario', i18n: 'nav.calendar', icon: 'calendar', href: 'calendario.html' },
|
||||
{
|
||||
label: 'Stakeholders', icon: 'users',
|
||||
cols: [{ head: 'RELAZIONI', links: [
|
||||
{ b: 'Stakeholders', s: 'Soggetti interni ed esterni rilevanti', href: 'stakeholders.html', ic: 'handshake' },
|
||||
{ b: 'Supply-Chain', s: 'Rischio fornitori e terze parti', href: 'supply-chain.html', ic: 'link' },
|
||||
{ b: 'Attività su Stakeholders', s: 'Interazioni, richieste e impegni', href: 'stakeholder-activities.html', ic: 'activity' }
|
||||
]}]
|
||||
},
|
||||
{
|
||||
label: 'Struttura interna', icon: 'building-2',
|
||||
cols: [{ head: 'RUOLI E COMPETENZE', links: [
|
||||
{ b: 'Organigramma', s: 'Ruoli, deleghe e responsabilità', href: 'organigramma.html', ic: 'network' },
|
||||
{ b: 'Competenze', s: 'Skill matrix e copertura dei ruoli', href: 'competenze.html', ic: 'award' },
|
||||
{ b: 'GAP Analysis', s: 'Scostamento tra stato attuale e atteso', href: 'assessment.html', ic: 'git-compare' },
|
||||
{ b: 'Formazione', s: 'Piani formativi e completamento', href: 'training.html', ic: 'graduation-cap' }
|
||||
]}]
|
||||
},
|
||||
{
|
||||
label: 'Rischi', icon: 'shield-alert',
|
||||
cols: [{ head: 'GESTIONE RISCHIO', links: [
|
||||
{ b: 'Mappa dei rischi', s: 'Registro rischi e piani di trattamento', href: 'risks.html', ic: 'map' },
|
||||
{ b: 'Inventario', s: 'Asset e perimetro tecnico', href: 'assets.html', ic: 'boxes' },
|
||||
{ b: 'Policy', s: 'Politiche approvate e in revisione', href: 'policies.html', ic: 'file-text' },
|
||||
{ b: 'Connettori Discovery', s: 'Scansione rete/cloud → inventario', href: 'connettori-discovery.html', ic: 'plug-zap' }
|
||||
]}]
|
||||
},
|
||||
{
|
||||
label: 'Monitoraggio', icon: 'radar',
|
||||
cols: [{ head: 'VIGILANZA E AUDIT', links: [
|
||||
{ b: 'Segnalazioni ricevute', s: 'Whistleblowing e segnalazioni interne', href: 'whistleblowing.html', ic: 'megaphone' },
|
||||
{ b: 'Audit interni', s: 'Programma e esiti degli audit', href: 'internal-audits.html', ic: 'clipboard-check' },
|
||||
{ b: 'Audit ACN', s: 'Verifiche e ispezioni dell’autorità', href: 'reports.html', ic: 'file-check' },
|
||||
{ b: 'Controlli periodici', s: 'Verifiche ricorrenti e check-list', href: 'controlli-periodici.html', ic: 'list-checks' },
|
||||
{ b: 'Riesame di Direzione', s: 'Esiti del riesame §9.3', href: 'management-review.html', ic: 'gauge' }
|
||||
]}]
|
||||
},
|
||||
{
|
||||
label: 'Lex e ISO', icon: 'scale',
|
||||
cols: [{ head: 'NORMATIVA E ISO', links: [
|
||||
{ b: 'Normative', s: 'Direttiva, decreto e riferimenti applicabili', href: 'normative.html', ic: 'scroll-text' },
|
||||
{ b: 'Modello SGSI (ISO 27001)', s: 'Sistema di gestione sicurezza informazioni', href: 'isms.html', ic: 'shield-check' },
|
||||
{ b: 'Misure e requisiti ACN', s: 'Misure tecniche e organizzative Art. 21', href: 'misure-requisiti.html', ic: 'list-checks' }
|
||||
]}]
|
||||
},
|
||||
{
|
||||
label: 'AI', icon: 'sparkles',
|
||||
cols: [{ head: 'ASSISTENTE', links: [
|
||||
{ b: 'AI Cross-Analysis', s: 'Analisi trasversale di rischi e misure', href: 'cross-analysis.html', ic: 'sparkles' },
|
||||
{ b: 'Knowledge Base', s: 'Procedure, linee guida e riferimenti', href: 'kb.html', ic: 'book-open' }
|
||||
]}]
|
||||
},
|
||||
{
|
||||
label: 'Guida', icon: 'life-buoy',
|
||||
cols: [
|
||||
{ head: 'GUIDA', links: [
|
||||
{ b: 'Guida all’uso', s: 'Manuale e istruzioni della piattaforma', href: 'guida.html', ic: 'life-buoy' },
|
||||
{ b: 'Integrazioni Esterne', s: 'Connettori e servizi collegati', href: 'integrazioniext.html', ic: 'plug' }
|
||||
]},
|
||||
{ head: 'SISTEMA', links: [
|
||||
{ b: 'Impostazioni', s: 'Configurazione del workspace', href: 'settings.html', ic: 'settings' },
|
||||
{ b: 'Architettura', s: 'Modello dati e struttura del sistema', href: 'architecture.html', ic: 'layers' },
|
||||
{ b: 'Simulazione Demo', s: 'Scenari guidati di prova', href: 'simulate.html', ic: 'play-circle' }
|
||||
]}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
|
||||
function currentPage() {
|
||||
return (window.location.pathname.split('/').pop() || 'dashboard.html');
|
||||
}
|
||||
|
||||
// È attivo il gruppo che contiene la pagina corrente (direct href o una delle links).
|
||||
function isGroupActive(g, page) {
|
||||
if (g.href) return g.href === page;
|
||||
if (g.cols) {
|
||||
for (var i = 0; i < g.cols.length; i++) {
|
||||
var lk = g.cols[i].links;
|
||||
for (var j = 0; j < lk.length; j++) if (lk[j].href === page) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderMega(g) {
|
||||
var colsHtml = g.cols.map(function (col) {
|
||||
var links = col.links.map(function (l) {
|
||||
return '<a class="mega-link" href="' + esc(l.href) + '">' +
|
||||
'<span class="mega-ic"><i data-lucide="' + esc(l.ic) + '" class="ic"></i></span>' +
|
||||
'<span><b>' + esc(l.b) + '</b><span>' + esc(l.s) + '</span></span></a>';
|
||||
}).join('');
|
||||
return '<div class="mega-col"><p class="mega-head">' + esc(col.head) + '</p>' + links + '</div>';
|
||||
}).join('');
|
||||
return '<div class="mega no-promo">' + colsHtml + '</div>';
|
||||
}
|
||||
|
||||
function renderNavItem(g, page) {
|
||||
var active = isGroupActive(g, page);
|
||||
var cls = 'nav-link' + (active ? ' active' : '');
|
||||
var aria = active ? ' aria-current="page"' : '';
|
||||
var i18n = g.i18n ? ' data-i18n="' + g.i18n + '"' : '';
|
||||
|
||||
if (g.href && !g.cols) {
|
||||
// Voce diretta (Dashboard, Calendario)
|
||||
return '<div class="nav-item">' +
|
||||
'<a class="' + cls + '"' + aria + ' href="' + esc(g.href) + '">' +
|
||||
'<i data-lucide="' + esc(g.icon) + '" class="ic"></i>' +
|
||||
'<span' + i18n + '>' + esc(g.label) + '</span></a></div>';
|
||||
}
|
||||
// Gruppo con mega-menu. Il link "testa" punta alla prima voce del gruppo.
|
||||
var firstHref = g.cols[0].links[0].href;
|
||||
return '<div class="nav-item">' +
|
||||
'<a class="' + cls + '"' + aria + ' href="' + esc(firstHref) + '">' +
|
||||
'<i data-lucide="' + esc(g.icon) + '" class="ic"></i>' +
|
||||
'<span' + i18n + '>' + esc(g.label) + '</span>' +
|
||||
'<i data-lucide="chevron-down" class="caret"></i></a>' +
|
||||
renderMega(g) + '</div>';
|
||||
}
|
||||
|
||||
function loadTopnavV3() {
|
||||
// Container: usa #topnav se presente, altrimenti riusa #sidebar (compat),
|
||||
// altrimenti crea un host e lo prepende al body.
|
||||
var host = document.getElementById('topnav') || document.getElementById('sidebar');
|
||||
if (!host) {
|
||||
host = document.createElement('div');
|
||||
host.id = 'topnav';
|
||||
document.body.insertBefore(host, document.body.firstChild);
|
||||
}
|
||||
// Se riusiamo #sidebar (aside), neutralizziamo eventuali stili layout V2.
|
||||
host.removeAttribute('class');
|
||||
host.id = 'topnav';
|
||||
|
||||
var page = currentPage();
|
||||
var lang = (typeof _getSavedLang === 'function') ? _getSavedLang() : 'it';
|
||||
|
||||
var navItems = NAV_GROUPS.map(function (g) { return renderNavItem(g, page); }).join('');
|
||||
|
||||
host.innerHTML =
|
||||
'<header class="appbar">' +
|
||||
'<div class="bar-top"><div class="bar-top-in">' +
|
||||
'<span class="flag" aria-hidden="true"><i class="g"></i><i class="w"></i><i class="r"></i></span>' +
|
||||
'<a class="logo-mark" href="dashboard.html" aria-label="NIS2 Agile — Dashboard">N2</a>' +
|
||||
'<div class="brand-txt"><b class="sidebar-logo-text">NIS2 Agile</b>' +
|
||||
'<span>Conformità Direttiva (UE) 2022/2555 · D.Lgs. 138/2024</span></div>' +
|
||||
'<label class="search"><i data-lucide="search" class="ic"></i>' +
|
||||
'<input id="topnav-search" placeholder="Cerca misura, rischio, incidente…" aria-label="Cerca"></label>' +
|
||||
'<span class="spacer"></span>' +
|
||||
// Org-switcher (consulenti / multi-org) — popolato da _loadUserInfo() via stesso id.
|
||||
'<span id="sidebar-org-switcher" style="display:none"></span>' +
|
||||
// Lingua IT/EN (riusa switchLang di common.js)
|
||||
'<span id="sidebar-lang-toggle" class="topnav-lang" role="group" aria-label="Lingua">' +
|
||||
'<button class="lang-btn ' + (lang === 'it' ? 'active' : '') + '" onclick="switchLang(\'it\')" title="Italiano" aria-label="Italiano">IT</button>' +
|
||||
'<button class="lang-btn ' + (lang === 'en' ? 'active' : '') + '" onclick="switchLang(\'en\')" title="English" aria-label="English">EN</button>' +
|
||||
'</span>' +
|
||||
'<a class="btn-incident" href="incidents.html"><i data-lucide="siren" class="ic"></i><span>Registra incidente</span></a>' +
|
||||
// User menu (avatar+nome+ruolo via _loadUserInfo; click = menu)
|
||||
'<span class="user" id="topnav-user" tabindex="0" role="button" aria-haspopup="true" aria-expanded="false">' +
|
||||
'<span class="avatar" id="sidebar-user-avatar">--</span>' +
|
||||
'<span class="sidebar-user-name" id="sidebar-user-name" style="max-width:140px;overflow:hidden;text-overflow:ellipsis">…</span>' +
|
||||
'<span id="sidebar-user-role" hidden></span>' +
|
||||
'<i data-lucide="chevron-down" class="ic"></i>' +
|
||||
'</span>' +
|
||||
'<button class="nav-toggle" id="topnav-toggle" aria-label="Apri menu" aria-expanded="false"><i data-lucide="menu" class="ic"></i></button>' +
|
||||
'</div></div>' +
|
||||
'<nav class="bar-nav" id="topnav-bar" aria-label="Navigazione principale"><div class="bar-nav-in">' +
|
||||
navItems +
|
||||
'</div></nav>' +
|
||||
'</header>';
|
||||
|
||||
// ── Riuso helper common.js (utente/avatar/ruolo + org-switcher + versione + branding + impersonate) ──
|
||||
if (typeof _loadUserInfo === 'function') _loadUserInfo();
|
||||
if (typeof _loadVersionFooter === 'function') _loadVersionFooter();
|
||||
if (typeof _loadFirmBranding === 'function') _loadFirmBranding();
|
||||
if (typeof _renderImpersonateBanner === 'function') _renderImpersonateBanner();
|
||||
|
||||
// ── Menu utente: dropdown con versione + logout (riusa api.logout) ──
|
||||
_wireUserMenu();
|
||||
// ── Mobile: toggle nav + accordion mega ──
|
||||
_wireMobileNav();
|
||||
// ── Stile + montaggio del trittico FAB reale dentro .fab-stack ──
|
||||
_styleRealFabs();
|
||||
|
||||
// Lucide: prova subito (se già caricato) — l'init pagina lo richiama comunque.
|
||||
if (window.lucide && typeof lucide.createIcons === 'function') {
|
||||
try { lucide.createIcons(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown del menu utente: versione (changelog) + logout.
|
||||
function _wireUserMenu() {
|
||||
var u = document.getElementById('topnav-user');
|
||||
if (!u) return;
|
||||
var menu = document.createElement('div');
|
||||
menu.id = 'topnav-user-menu';
|
||||
menu.style.cssText = 'position:fixed;display:none;min-width:200px;background:#fff;color:var(--ink);' +
|
||||
'border:1px solid var(--line);border-radius:10px;box-shadow:0 14px 36px rgba(11,40,80,.20);' +
|
||||
'padding:6px;z-index:90';
|
||||
menu.innerHTML =
|
||||
'<div id="sidebar-version" role="button" tabindex="0" title="Versione applicazione" ' +
|
||||
'style="padding:8px 10px;font-size:12px;color:var(--muted);cursor:pointer;border-radius:8px">v—</div>' +
|
||||
'<a href="settings.html" style="display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:8px;font-size:13px;font-weight:600">' +
|
||||
'<i data-lucide="settings" style="width:15px;height:15px"></i>Impostazioni</a>' +
|
||||
'<button type="button" id="topnav-logout" style="display:flex;align-items:center;gap:8px;width:100%;text-align:left;' +
|
||||
'border:0;background:none;font:inherit;font-size:13px;font-weight:600;color:var(--c-red);padding:9px 10px;border-radius:8px;cursor:pointer">' +
|
||||
'<i data-lucide="log-out" style="width:15px;height:15px"></i>Esci</button>';
|
||||
document.body.appendChild(menu);
|
||||
|
||||
function place() {
|
||||
var r = u.getBoundingClientRect();
|
||||
menu.style.top = (r.bottom + 8) + 'px';
|
||||
menu.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
|
||||
}
|
||||
function open() { place(); menu.style.display = 'block'; u.setAttribute('aria-expanded', 'true'); if (window.lucide) try { lucide.createIcons(); } catch (e) {} }
|
||||
function close() { menu.style.display = 'none'; u.setAttribute('aria-expanded', 'false'); }
|
||||
function toggle() { (menu.style.display === 'block') ? close() : open(); }
|
||||
|
||||
u.addEventListener('click', toggle);
|
||||
u.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
|
||||
document.addEventListener('click', function (e) { if (!u.contains(e.target) && !menu.contains(e.target)) close(); });
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
|
||||
|
||||
var lo = menu.querySelector('#topnav-logout');
|
||||
if (lo) lo.addEventListener('click', function () { if (typeof api !== 'undefined' && api.logout) api.logout(); });
|
||||
var ver = menu.querySelector('#sidebar-version');
|
||||
if (ver) {
|
||||
ver.addEventListener('click', function () { if (typeof _showVersionChangelog === 'function') _showVersionChangelog(); });
|
||||
ver.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (typeof _showVersionChangelog === 'function') _showVersionChangelog(); } });
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile (≤900): hamburger apre/chiude la nav; tap su un gruppo apre l'accordion mega.
|
||||
function _wireMobileNav() {
|
||||
var toggle = document.getElementById('topnav-toggle');
|
||||
var bar = document.getElementById('topnav-bar');
|
||||
if (toggle && bar) {
|
||||
toggle.addEventListener('click', function () {
|
||||
var open = bar.classList.toggle('open');
|
||||
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
// Accordion per i gruppi con mega (solo in modalità mobile via CSS).
|
||||
document.querySelectorAll('#topnav-bar .nav-item').forEach(function (item) {
|
||||
var hasMega = item.querySelector('.mega');
|
||||
if (!hasMega) return;
|
||||
var link = item.querySelector('.nav-link');
|
||||
if (!link) return;
|
||||
link.addEventListener('click', function (e) {
|
||||
// Solo in layout mobile (nav-toggle visibile) intercettiamo il tap per aprire l'accordion.
|
||||
if (toggle && getComputedStyle(toggle).display !== 'none') {
|
||||
e.preventDefault();
|
||||
item.classList.toggle('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Trittico FAB reale (campana/bug-reporter ARIA) montato da common.js: lo
|
||||
// raggruppiamo in .fab-stack e gli diamo la veste V3, senza reimplementarlo.
|
||||
// common.js crea: #nx-bar (bug-reporter widget) e #ai-chat-fab (ARIA viola).
|
||||
// Qui aggiungiamo SOLO un contenitore visivo se non presente; gli elementi
|
||||
// restano gestiti dal loro codice (eventi/voce/puntatore invariati).
|
||||
function _styleRealFabs() {
|
||||
// I FAB reali sono position:fixed indipendenti (z-index alti). Non li
|
||||
// spostiamo nel DOM per non romperne gli handler; il loro look resta
|
||||
// quello di common.js. Questa funzione è un hook no-op di compatibilità
|
||||
// mantenuto per chiarezza: la veste .fab del mockup NON deve sovrascrivere
|
||||
// i FAB funzionanti (ARIA con voce + bug-reporter widget).
|
||||
return;
|
||||
}
|
||||
|
||||
// CSS one-shot per il lang-toggle e user menu nella appbar (token V3).
|
||||
(function injectChromeCss() {
|
||||
if (document.getElementById('topnav-v3-css')) return;
|
||||
var s = document.createElement('style');
|
||||
s.id = 'topnav-v3-css';
|
||||
s.textContent =
|
||||
'.topnav-lang{display:inline-flex;gap:3px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.16);border-radius:8px;padding:3px}' +
|
||||
'.topnav-lang .lang-btn{border:0;background:transparent;color:#cfe0f1;font:inherit;font-size:11px;font-weight:700;padding:4px 8px;border-radius:6px;cursor:pointer}' +
|
||||
'.topnav-lang .lang-btn:hover{background:rgba(255,255,255,.12);color:#fff}' +
|
||||
'.topnav-lang .lang-btn.active{background:var(--gold);color:#1a1a1a}' +
|
||||
'#topnav-user .sidebar-user-name{font-size:12px;font-weight:600}' +
|
||||
'@media (max-width:560px){#topnav-user .sidebar-user-name{display:none}.topnav-lang{display:none}}';
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
|
||||
// Override globale: l'init delle pagine chiama loadSidebar() invariato.
|
||||
window.loadTopnavV3 = loadTopnavV3;
|
||||
window.loadSidebar = loadTopnavV3;
|
||||
})();
|
||||
Reference in New Issue
Block a user