nis2-agile/public/js/common.js
DevEnv nis2-agile 59fad22c0e [UX+SEC] Eccellenza pre-audit: idle timeout, loading states, i18n, UX polish
- common.js: idle session timeout 30min con avviso countdown 5min prima del logout
- common.js: checkAuth() attiva automaticamente il monitor di inattività
- api.js: messaggi errore connessione usano i18n (IT/EN) tramite I18n.t()
- risks.html: saveRisk() e aiSuggest() con setButtonLoading durante salvataggio
- risks.html: deleteRisk() ricarica la matrice se si è in matrix view
- incidents.html: createIncident() con setButtonLoading durante registrazione
- policies.html: savePolicy() e saveAIGeneratedPolicy() con setButtonLoading
- policies.html: banner AI-draft con pulsante X per dismissione

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 12:25:52 +01:00

780 lines
36 KiB
JavaScript

/**
* NIS2 Agile - Common Utilities
*
* Funzioni condivise tra tutte le pagine del frontend.
*/
// ═══════════════════════════════════════════════════════════════════
// Notifications (Toast)
// ═══════════════════════════════════════════════════════════════════
/**
* Mostra una notifica toast.
* @param {string} message - Testo del messaggio
* @param {string} type - 'success' | 'error' | 'warning' | 'info'
* @param {number} duration - Durata in ms (default 4000)
*/
function showNotification(message, type = 'info', duration = 4000) {
let container = document.querySelector('.notification-container');
if (!container) {
container = document.createElement('div');
container.className = 'notification-container';
document.body.appendChild(container);
}
const icons = {
success: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>',
error: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>',
warning: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>',
info: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>'
};
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.innerHTML = `
<span class="notification-icon">${icons[type] || icons.info}</span>
<span class="notification-message">${escapeHtml(message)}</span>
<button class="notification-close" onclick="this.parentElement.remove()">&times;</button>
`;
container.appendChild(notification);
setTimeout(() => {
notification.classList.add('fade-out');
setTimeout(() => notification.remove(), 300);
}, duration);
}
// ═══════════════════════════════════════════════════════════════════
// Modal
// ═══════════════════════════════════════════════════════════════════
/**
* Mostra un dialogo modale.
* @param {string} title - Titolo del modale
* @param {string} content - Contenuto HTML del body
* @param {object} options - { footer: HTML, size: 'sm'|'md'|'lg' }
*/
function showModal(title, content, options = {}) {
closeModal(); // Chiudi eventuale modale aperto
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'modal-overlay';
const sizeClass = options.size === 'lg' ? 'style="max-width:720px"' : options.size === 'sm' ? 'style="max-width:400px"' : '';
overlay.innerHTML = `
<div class="modal" ${sizeClass}>
<div class="modal-header">
<h3>${escapeHtml(title)}</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">${content}</div>
${options.footer ? `<div class="modal-footer">${options.footer}</div>` : ''}
</div>
`;
// Chiudi cliccando fuori dal modale
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal();
});
document.body.appendChild(overlay);
// Trigger animation
requestAnimationFrame(() => {
overlay.classList.add('active');
});
// Chiudi con ESC
document.addEventListener('keydown', _modalEscHandler);
}
function closeModal() {
const overlay = document.getElementById('modal-overlay');
if (overlay) {
overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 250);
}
document.removeEventListener('keydown', _modalEscHandler);
}
function _modalEscHandler(e) {
if (e.key === 'Escape') closeModal();
}
// ═══════════════════════════════════════════════════════════════════
// Date Formatting
// ═══════════════════════════════════════════════════════════════════
/**
* Formatta una data in formato italiano (dd/mm/yyyy).
* @param {string|Date} date
* @returns {string}
*/
function formatDate(date) {
if (!date) return '-';
const d = new Date(date);
if (isNaN(d.getTime())) return '-';
return d.toLocaleDateString('it-IT', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
/**
* Formatta data e ora in formato italiano.
* @param {string|Date} date
* @returns {string}
*/
function formatDateTime(date) {
if (!date) return '-';
const d = new Date(date);
if (isNaN(d.getTime())) return '-';
return d.toLocaleDateString('it-IT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Restituisce un'etichetta relativa (es. "2 ore fa", "ieri").
* @param {string|Date} date
* @returns {string}
*/
function timeAgo(date) {
if (!date) return '';
const d = new Date(date);
const now = new Date();
const diffMs = now - d;
const diffMin = Math.floor(diffMs / 60000);
const diffHrs = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMin < 1) return 'Adesso';
if (diffMin < 60) return `${diffMin} min fa`;
if (diffHrs < 24) return `${diffHrs} ore fa`;
if (diffDays === 1) return 'Ieri';
if (diffDays < 7) return `${diffDays} giorni fa`;
return formatDate(date);
}
// ═══════════════════════════════════════════════════════════════════
// Sidebar
// ═══════════════════════════════════════════════════════════════════
/**
* Inietta la sidebar di navigazione nell'elemento #sidebar.
*/
function loadSidebar() {
const container = document.getElementById('sidebar');
if (!container) return;
// Determina la pagina corrente
const currentPage = window.location.pathname.split('/').pop() || 'dashboard.html';
const navItems = [
{
label: 'Principale', i18nKey: 'nav.main',
items: [
{ name: 'Dashboard', href: 'dashboard.html', icon: iconGrid(), i18nKey: 'nav.dashboard' },
{ name: 'Gap Analysis', href: 'assessment.html', icon: iconClipboardCheck(), i18nKey: 'nav.gap_analysis' },
]
},
{
label: 'Gestione', i18nKey: 'nav.management',
items: [
{ name: 'Rischi', href: 'risks.html', icon: iconShieldExclamation(), i18nKey: 'nav.risks' },
{ name: 'Incidenti', href: 'incidents.html', icon: iconBell(), i18nKey: 'nav.incidents' },
{ name: 'Policy', href: 'policies.html', icon: iconDocumentText(), i18nKey: 'nav.policies' },
{ name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink(), i18nKey: 'nav.supply_chain' },
]
},
{
label: 'Operativo', i18nKey: 'nav.operations',
items: [
{ name: 'Formazione', href: 'training.html', icon: iconAcademicCap(), i18nKey: 'nav.training' },
{ name: 'Asset', href: 'assets.html', icon: iconServer(), i18nKey: 'nav.assets' },
{ name: 'Audit & Report',href: 'reports.html', icon: iconChartBar(), i18nKey: 'nav.audit' },
]
},
{
label: 'Sistema', i18nKey: 'nav.system',
items: [
{ name: 'Impostazioni', href: 'settings.html', icon: iconCog(), i18nKey: 'nav.settings' },
{ name: 'Architettura', href: 'architecture.html', icon: iconCubeTransparent() },
]
}
];
let navHTML = '';
// Brand
navHTML += `
<div class="sidebar-brand">
<div class="sidebar-brand-icon">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/><path d="M10 12.5l-2-2-1.41 1.41L10 15.32l5.41-5.41L14 8.5l-4 4z"/></svg>
</div>
<div>
<h1>NIS2 Agile</h1>
<span>Compliance Platform</span>
</div>
</div>
`;
// Org-switcher area (populated async for consultants)
navHTML += `<div id="sidebar-org-switcher" style="display:none;padding:8px 12px;border-bottom:1px solid var(--gray-100);"></div>`;
// Nav sections
navHTML += '<nav class="sidebar-nav">';
for (const section of navItems) {
const sectionI18n = section.i18nKey ? ` data-i18n="${section.i18nKey}"` : '';
navHTML += `<div class="sidebar-nav-label"${sectionI18n}>${section.label}</div>`;
for (const item of section.items) {
const isActive = currentPage === item.href ? 'active' : '';
const spanI18n = item.i18nKey ? ` data-i18n="${item.i18nKey}"` : '';
navHTML += `<a href="${item.href}" class="${isActive}">${item.icon}<span${spanI18n}>${item.name}</span></a>`;
}
}
navHTML += '</nav>';
// Footer with language toggle + user info
navHTML += `
<div class="sidebar-footer">
<div class="sidebar-lang-toggle" id="sidebar-lang-toggle">
<button class="lang-btn ${_getSavedLang() === 'it' ? 'active' : ''}" onclick="switchLang('it')" title="Italiano" aria-label="Italiano">IT</button>
<button class="lang-btn ${_getSavedLang() === 'en' ? 'active' : ''}" onclick="switchLang('en')" title="English" aria-label="English">EN</button>
</div>
<div class="sidebar-user">
<div class="sidebar-user-avatar" id="sidebar-user-avatar">--</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name" id="sidebar-user-name">Caricamento...</div>
<div class="sidebar-user-role" id="sidebar-user-role">Utente</div>
</div>
<button class="sidebar-logout-btn" onclick="api.logout()" title="Esci">
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 001 1h6a1 1 0 100-2H4V5h5a1 1 0 100-2H3zm11.707 3.293a1 1 0 010 1.414L12.414 10l2.293 2.293a1 1 0 01-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0z" clip-rule="evenodd"/><path fill-rule="evenodd" d="M16 10a1 1 0 00-1-1H8a1 1 0 100 2h7a1 1 0 001-1z" clip-rule="evenodd"/></svg>
</button>
</div>
</div>
`;
container.innerHTML = navHTML;
// Carica info utente (e org-switcher se consulente)
_loadUserInfo();
// Mobile toggle
_setupMobileToggle();
}
const _roleLabels = {
super_admin: 'Super Admin',
org_admin: 'Admin Org',
compliance_manager: 'Compliance Manager',
board_member: 'Membro CDA',
auditor: 'Auditor',
employee: 'Utente',
consultant: 'Consulente',
};
async function _loadUserInfo() {
try {
const result = await api.getMe();
if (result.success && result.data) {
const user = result.data;
const nameEl = document.getElementById('sidebar-user-name');
const avatarEl = document.getElementById('sidebar-user-avatar');
const roleEl = document.getElementById('sidebar-user-role');
if (nameEl) nameEl.textContent = user.full_name || user.email;
if (avatarEl) {
const initials = (user.full_name || user.email || '--')
.split(' ')
.map(w => w[0])
.slice(0, 2)
.join('')
.toUpperCase();
avatarEl.textContent = initials;
}
if (roleEl) roleEl.textContent = _roleLabels[user.role] || user.role || 'Utente';
// Save role to localStorage (ensures isConsultant() works across pages)
if (user.role) api.setUserRole(user.role);
// For consultants: render org-switcher
if (user.role === 'consultant') {
_loadConsultantOrgSwitcher();
}
}
} catch (e) {
// Silenzioso
}
}
async function _loadConsultantOrgSwitcher() {
try {
const result = await api.listOrganizations();
if (!result.success || !result.data || result.data.length === 0) return;
const orgs = result.data;
const currentOrgId = parseInt(api.orgId);
const currentOrg = orgs.find(o => (o.id || o.organization_id) === currentOrgId);
const switcher = document.getElementById('sidebar-org-switcher');
if (!switcher) return;
const dropdownId = 'org-dropdown-' + Date.now();
switcher.style.display = 'block';
switcher.innerHTML = `
<div style="position:relative">
<button onclick="document.getElementById('${dropdownId}').classList.toggle('open')"
style="width:100%;display:flex;align-items:center;gap:8px;padding:6px 10px;
background:var(--gray-50);border:1px solid var(--gray-200);border-radius:6px;
cursor:pointer;font-size:.8rem;font-weight:600;color:var(--gray-700);">
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor" style="flex-shrink:0;color:var(--primary)">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4zm3 1h2v2H7V5zm2 4H7v2h2V9zm2-4h2v2h-2V5zm2 4h-2v2h2V9z" clip-rule="evenodd"/>
</svg>
<span style="flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${currentOrg ? currentOrg.name : 'Seleziona azienda'}
</span>
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" style="flex-shrink:0">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</button>
<div id="${dropdownId}" class="org-switcher-dropdown"
style="display:none;position:absolute;left:0;right:0;top:calc(100% + 4px);
background:var(--card-bg);border:1px solid var(--gray-200);border-radius:6px;
box-shadow:var(--card-shadow);z-index:200;overflow:hidden;">
${orgs.map(org => {
const orgId = org.id || org.organization_id;
const isActive = orgId === currentOrgId;
return `<button onclick="_switchOrg(${orgId})"
style="width:100%;text-align:left;padding:8px 12px;
background:${isActive ? 'var(--primary-bg)' : 'transparent'};
border:none;cursor:pointer;font-size:.8rem;
color:${isActive ? 'var(--primary)' : 'var(--gray-700)'};
font-weight:${isActive ? '600' : '400'};">
${isActive ? '✓ ' : ''}${org.name}
</button>`;
}).join('')}
<div style="border-top:1px solid var(--gray-100)">
<a href="companies.html"
style="display:block;padding:8px 12px;font-size:.8rem;
color:var(--primary);text-decoration:none;">
← Tutte le aziende
</a>
</div>
</div>
</div>`;
// Close on outside click
document.addEventListener('click', (e) => {
const dd = document.getElementById(dropdownId);
if (dd && !switcher.contains(e.target)) dd.style.display = 'none';
});
// Override toggle to handle display
const ddEl = document.getElementById(dropdownId);
if (ddEl) {
const btn = switcher.querySelector('button');
if (btn) btn.onclick = () => {
ddEl.style.display = ddEl.style.display === 'none' ? 'block' : 'none';
};
}
} catch (e) {
// Silenzioso
}
}
function _switchOrg(orgId) {
api.setOrganization(orgId);
window.location.reload();
}
function _setupMobileToggle() {
// Crea pulsante toggle se non esiste
if (!document.querySelector('.sidebar-toggle')) {
const toggle = document.createElement('button');
toggle.className = 'sidebar-toggle';
toggle.setAttribute('aria-label', 'Apri menu');
toggle.innerHTML = '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>';
toggle.addEventListener('click', () => _toggleSidebar());
document.body.appendChild(toggle);
}
// Crea backdrop per mobile
if (!document.querySelector('.sidebar-backdrop')) {
const backdrop = document.createElement('div');
backdrop.className = 'sidebar-backdrop';
backdrop.addEventListener('click', () => _toggleSidebar(false));
document.body.appendChild(backdrop);
}
}
function _toggleSidebar(forceState) {
const sidebar = document.getElementById('sidebar');
const backdrop = document.querySelector('.sidebar-backdrop');
if (!sidebar) return;
const isOpen = typeof forceState === 'boolean' ? forceState : !sidebar.classList.contains('open');
if (isOpen) {
sidebar.classList.add('open');
if (backdrop) backdrop.classList.add('visible');
} else {
sidebar.classList.remove('open');
if (backdrop) backdrop.classList.remove('visible');
}
}
// ═══════════════════════════════════════════════════════════════════
// Auth Check
// ═══════════════════════════════════════════════════════════════════
/**
* Verifica che l'utente sia autenticato. Se non lo e', redirige al login.
* Attiva automaticamente il timeout di sessione per inattivita'.
* @returns {boolean}
*/
function checkAuth() {
if (!api.isAuthenticated()) {
window.location.href = 'login.html';
return false;
}
if (!_idleInitialized) {
_idleInitialized = true;
initIdleTimeout();
}
return true;
}
// ═══════════════════════════════════════════════════════════════════
// Idle Session Timeout
// ═══════════════════════════════════════════════════════════════════
let _idleTimer = null;
let _idleWarningTimer = null;
let _idleCountdownInterval = null;
let _idleInitialized = false;
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minuti totali
const IDLE_WARNING_MS = 5 * 60 * 1000; // avviso 5 minuti prima della scadenza
/**
* Avvia il monitoraggio di inattivita' della sessione.
* Mostra un avviso 5 minuti prima del logout automatico.
*/
function initIdleTimeout() {
_resetIdleTimer();
['mousemove', 'keydown', 'click', 'scroll', 'touchstart'].forEach(evt => {
document.addEventListener(evt, _resetIdleTimer, { passive: true });
});
}
function _resetIdleTimer() {
clearTimeout(_idleTimer);
clearTimeout(_idleWarningTimer);
clearInterval(_idleCountdownInterval);
// Chiudi avviso se gia' aperto
const existingWarning = document.getElementById('idle-warning-overlay');
if (existingWarning) existingWarning.remove();
// Timer per mostrare avviso
_idleWarningTimer = setTimeout(_showIdleWarning, IDLE_TIMEOUT_MS - IDLE_WARNING_MS);
// Timer per logout automatico
_idleTimer = setTimeout(() => {
if (typeof api !== 'undefined') api.logout();
}, IDLE_TIMEOUT_MS);
}
function _showIdleWarning() {
const existing = document.getElementById('idle-warning-overlay');
if (existing) existing.remove();
let remaining = Math.floor(IDLE_WARNING_MS / 1000);
const overlay = document.createElement('div');
overlay.id = 'idle-warning-overlay';
overlay.style.cssText = [
'position:fixed', 'top:0', 'left:0', 'right:0', 'bottom:0', 'z-index:9999',
'background:rgba(0,0,0,0.55)', 'display:flex', 'align-items:center', 'justify-content:center'
].join(';');
overlay.innerHTML = `
<div style="background:var(--card-bg,#fff);border-radius:12px;padding:32px;max-width:400px;width:90%;text-align:center;box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="width:56px;height:56px;background:#fff7ed;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
<svg width="28" height="28" viewBox="0 0 20 20" fill="#c2410c"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
</div>
<h3 style="font-size:1.1rem;font-weight:700;color:var(--gray-800,#1f2937);margin-bottom:8px;">Sessione in scadenza</h3>
<p style="font-size:0.875rem;color:var(--gray-500,#6b7280);margin-bottom:20px;">
Per motivi di sicurezza, verrai disconnesso tra<br>
<strong id="idle-countdown" style="font-size:1.5rem;color:#c2410c;display:block;margin:8px 0;">5:00</strong>
a causa di inattivita'.
</p>
<div style="display:flex;gap:12px;justify-content:center;">
<button id="idle-stay-btn"
style="padding:10px 24px;background:var(--primary,#2563eb);color:#fff;border:none;border-radius:8px;font-weight:600;cursor:pointer;font-size:0.875rem;">
Rimani connesso
</button>
<button onclick="api.logout();"
style="padding:10px 24px;background:var(--gray-100,#f3f4f6);color:var(--gray-700,#374151);border:none;border-radius:8px;font-weight:600;cursor:pointer;font-size:0.875rem;">
Disconnetti
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
document.getElementById('idle-stay-btn').addEventListener('click', () => {
overlay.remove();
_resetIdleTimer();
});
_idleCountdownInterval = setInterval(() => {
remaining--;
const min = Math.floor(remaining / 60);
const sec = remaining % 60;
const el = document.getElementById('idle-countdown');
if (el) el.textContent = `${min}:${sec.toString().padStart(2, '0')}`;
if (remaining <= 0) clearInterval(_idleCountdownInterval);
}, 1000);
}
// ═══════════════════════════════════════════════════════════════════
// Utilities
// ═══════════════════════════════════════════════════════════════════
/**
* Escape HTML per prevenire XSS.
* @param {string} str
* @returns {string}
*/
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
/**
* Debounce: ritarda l'esecuzione di fn fino a delay ms dopo l'ultima chiamata.
* @param {Function} fn
* @param {number} delay
* @returns {Function}
*/
function debounce(fn, delay = 300) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
/**
* Restituisce la classe CSS per un punteggio di conformita'.
* @param {number} score - Valore 0-100
* @returns {string}
*/
function getScoreClass(score) {
if (score >= 80) return 'score-excellent';
if (score >= 60) return 'score-good';
if (score >= 40) return 'score-medium';
if (score >= 20) return 'score-low';
return 'score-critical';
}
/**
* Restituisce il colore esadecimale per un punteggio.
* @param {number} score - Valore 0-100
* @returns {string}
*/
function getScoreColor(score) {
if (score >= 80) return '#34a853';
if (score >= 60) return '#84cc16';
if (score >= 40) return '#fbbc04';
if (score >= 20) return '#f97316';
return '#ea4335';
}
/**
* Crea l'SVG per il gauge circolare del punteggio.
* @param {number} score - Valore 0-100
* @param {number} size - Dimensione in px (default 180)
* @returns {string} HTML
*/
function renderScoreGauge(score, size = 180) {
const radius = (size / 2) - 14;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (score / 100) * circumference;
const color = getScoreColor(score);
const cls = getScoreClass(score);
return `
<div class="score-gauge ${cls}" style="width:${size}px;height:${size}px">
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<circle class="score-gauge-circle-bg" cx="${size/2}" cy="${size/2}" r="${radius}"/>
<circle class="score-gauge-circle" cx="${size/2}" cy="${size/2}" r="${radius}"
stroke="${color}"
stroke-dasharray="${circumference}"
stroke-dashoffset="${offset}"
style="--score-color: ${color}"/>
</svg>
<div class="score-gauge-value">
<div class="score-gauge-number">${Math.round(score)}</div>
<div class="score-gauge-label">Compliance</div>
</div>
</div>
`;
}
// ═══════════════════════════════════════════════════════════════════
// SVG Icons (inline, nessuna dipendenza esterna)
// ═══════════════════════════════════════════════════════════════════
function iconGrid() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 8a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zm6-6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zm0 8a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>';
}
function iconClipboardCheck() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>';
}
function iconShieldExclamation() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd"/></svg>';
}
function iconBell() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z"/></svg>';
}
function iconDocumentText() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>';
}
function iconLink() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"/></svg>';
}
function iconAcademicCap() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838L7.667 9.088l1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zM9.3 16.573A9.026 9.026 0 007 14.935v-3.957l1.818.78a3 3 0 002.364 0l5.508-2.361a11.026 11.026 0 01.25 3.762 1 1 0 01-.89.89 8.968 8.968 0 00-5.35 2.524 1 1 0 01-1.4 0z"/></svg>';
}
function iconServer() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clip-rule="evenodd"/></svg>';
}
function iconChartBar() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zm6-4a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zm6-3a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/></svg>';
}
function iconCubeTransparent() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 3.5a1 1 0 00-.5.134l-5 2.857A1 1 0 004 7.382v5.236a1 1 0 00.5.866l5 2.857a1 1 0 001 0l5-2.857a1 1 0 00.5-.866V7.382a1 1 0 00-.5-.891l-5-2.857A1 1 0 0010 3.5zM6 8.382l4 2.286 4-2.286v3.236l-4 2.286-4-2.286V8.382z"/></svg>';
}
function iconQuestionMarkCircle() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></svg>';
}
function iconCog() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>';
}
/**
* Imposta lo stato di caricamento su un bottone.
* @param {HTMLElement|string} btn - Elemento o ID
* @param {boolean} loading - true per attivare, false per disattivare
* @param {string} [originalText] - Testo originale da ripristinare
*/
function setButtonLoading(btn, loading, originalText) {
if (typeof btn === 'string') btn = document.getElementById(btn);
if (!btn) return;
if (loading) {
btn._originalText = btn.textContent;
btn.classList.add('loading');
btn.disabled = true;
} else {
btn.classList.remove('loading');
btn.disabled = false;
if (originalText) btn.textContent = originalText;
else if (btn._originalText) btn.textContent = btn._originalText;
}
}
function iconPlus() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>';
}
function iconSparkles() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>';
}
// ═══════════════════════════════════════════════════════════════════
// Language Switcher
// ═══════════════════════════════════════════════════════════════════
function _getSavedLang() {
return localStorage.getItem('nis2_lang') || 'it';
}
function switchLang(lang) {
if (typeof I18n !== 'undefined') {
I18n.setLang(lang);
}
localStorage.setItem('nis2_lang', lang);
// Aggiorna bottoni
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.classList.toggle('active', btn.textContent.trim().toLowerCase() === lang);
});
}
// Inject language toggle CSS
(function _injectLangCSS() {
if (document.getElementById('lang-toggle-css')) return;
const style = document.createElement('style');
style.id = 'lang-toggle-css';
style.textContent = `
.sidebar-lang-toggle {
display: flex;
justify-content: center;
gap: 4px;
padding: 8px 16px 4px;
}
.lang-btn {
padding: 4px 14px;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid var(--gray-300);
background: var(--gray-50);
color: var(--gray-500);
border-radius: 4px;
cursor: pointer;
transition: all var(--transition-fast);
font-family: inherit;
}
.lang-btn:hover {
background: var(--gray-100);
color: var(--gray-700);
}
.lang-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
`;
document.head.appendChild(style);
})();