Files
nis2-agile/public/training.html
T
DevEnv nis2-agileandClaude Opus 4.8 41476c88f2 [FIX] api.js: sessione scaduta → login invece di pagina bloccata "--"
Bug segnalato da un tester "sono tornato su nis2 e non funziona più" (utente/
aziende "--", inventario bloccato su Caricamento, niente FAB segnalazioni).
Causa: un 401 su /auth/refresh (refresh token scaduto/invalido) rientrava nel
handler di refresh (la condizione non escludeva l'endpoint auth) → ricorsione:
doRefreshToken() non ritornava mai, quindi logout()/redirect a login non
scattava e la pagina restava rotta. Riprodotto con puppeteer (access scaduto +
refresh invalido → dashboard "--" identica allo screenshot).

Fix: escludi /auth/refresh e /auth/login dall'auto-refresh; inoltre un 401 senza
refresh token disponibile fa logout() → login. Ora un utente che torna dopo la
scadenza del token viene mandato a login (ri-accede) invece di vedere "--".
Bug PRE-ESISTENTE in api.js (non introdotto dal V3), critico ora col push tester.
Cache-buster api.js ?v=20260627u su tutte le pagine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:09:50 +02:00

649 lines
36 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>Formazione · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css. No external CDN, no Bootstrap Italia. -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style>
/* V3+ page-specific (componenti non coperti da v3.css): tab interni, card corso,
tabella, badge stato, barra compliance. + componenti prodotti dalla JS inline via
common.js (showModal/showNotification): modal-* / form-* / .btn-secondary, mappati a token V3
perche' style.css e' rimosso. */
/* ── Tab Navigation ─────────────────────────────────────── */
.tab-nav { display:flex; gap:0; border-bottom:2px solid var(--line); margin-bottom:24px; }
.tab-nav button { padding:12px 22px; background:none; border:none; border-bottom:2px solid transparent; margin-bottom:-2px; color:var(--muted); font:inherit; font-size:13.5px; font-weight:600; cursor:pointer; }
.tab-nav button:hover { color:var(--ink); background:#F5F8FC; }
.tab-nav button.active { color:var(--c-blue); border-bottom-color:var(--c-blue); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
/* ── Course Cards Grid ──────────────────────────────────── */
.courses-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(320px, 1fr)); gap:18px; }
.course-card { background:#fff; border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:18px 20px; position:relative; display:flex; flex-direction:column; }
.course-card:hover { box-shadow:0 8px 20px rgba(16,40,80,.13); }
.course-card-header { display:flex; justify-content:space-between; align-items:flex-start; gap:10px; margin-bottom:12px; }
.course-card-title { font-size:15px; font-weight:700; color:var(--ink); margin:0; }
.course-card-desc { font-size:13px; color:var(--muted); margin-bottom:16px; flex-grow:1; line-height:1.5; }
.course-card-meta { display:flex; flex-wrap:wrap; gap:8px; align-items:center; font-size:12px; }
.course-meta-item { display:inline-flex; align-items:center; gap:4px; color:var(--muted); background:#EEF2F7; padding:4px 10px; border-radius:20px; }
.course-meta-item svg { width:14px; height:14px; }
.badge-mandatory { background:#FBE9E9; color:#B23636; font-weight:700; padding:3px 10px; border-radius:20px; font-size:10.5px; text-transform:uppercase; letter-spacing:.04em; }
/* ── Tables ─────────────────────────────────────────────── */
.data-table { width:100%; border-collapse:collapse; background:#fff; border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; box-shadow:var(--shadow); }
.data-table thead { background:#FAFCFE; }
.data-table th { text-align:left; padding:12px 16px; font-size:11px; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; border-bottom:1px solid var(--line); }
.data-table td { padding:12px 16px; font-size:13.5px; color:var(--ink); border-bottom:1px solid var(--line); }
.data-table tr:last-child td { border-bottom:none; }
.data-table tr:hover td { background:#FAFCFE; }
/* ── Status Badges ──────────────────────────────────────── */
.status-badge { display:inline-flex; align-items:center; padding:4px 12px; border-radius:20px; font-size:11px; font-weight:700; gap:4px; }
.status-badge.assigned { background:#EEF2F7; color:#5b6b80; }
.status-badge.in_progress { background:#E0F2FE; color:#075985; }
.status-badge.completed { background:#E6F4EC; color:#1E7A4E; }
.status-badge.overdue { background:#FBE9E9; color:#B23636; }
/* ── Compliance Bar ─────────────────────────────────────── */
.compliance-bar-container { display:flex; align-items:center; gap:10px; }
.compliance-bar { flex-grow:1; height:8px; background:#EAF0F6; border-radius:4px; overflow:hidden; }
.compliance-bar-fill { height:100%; border-radius:4px; transition:width .3s ease; }
.compliance-pct { font-size:13px; font-weight:700; min-width:45px; text-align:right; }
.compliance-pct.high { color:var(--c-green); }
.compliance-pct.medium { color:var(--c-amber); }
.compliance-pct.low { color:var(--c-red); }
/* ── Loading / Empty ────────────────────────────────────── */
.loading-state { text-align:center; padding:48px 20px; color:var(--muted); }
.loading-state .spinner { width:32px; height:32px; border:3px solid var(--line); border-top-color:var(--c-blue); border-radius:50%; animation:spin .8s linear infinite; margin:0 auto 12px; }
@keyframes spin { to { transform:rotate(360deg); } }
@media (prefers-reduced-motion:reduce){ .loading-state .spinner{ animation:none; } }
.empty-state-box { text-align:center; padding:48px 20px; color:var(--muted); }
.empty-state-box svg { width:48px; height:48px; margin-bottom:12px; opacity:.45; }
.empty-state-box h4 { color:var(--ink); margin:0 0 4px; font-size:15px; font-weight:700; }
.text-muted { color:var(--muted); }
/* ── Modale (prodotta da common.js showModal — style.css rimosso) ── */
.modal-overlay { position:fixed; inset:0; background:rgba(15,38,60,.6); backdrop-filter:blur(4px); z-index:1000; display:flex; align-items:center; justify-content:center; opacity:0; visibility:hidden; transition:opacity .2s,visibility .2s; }
.modal-overlay.active { opacity:1; visibility:visible; }
.modal { background:#fff; border-radius:14px; box-shadow:0 20px 60px rgba(11,40,80,.25); width:90%; max-width:560px; max-height:85vh; overflow-y:auto; transform:translateY(20px) scale(.97); transition:transform .2s; }
.modal-overlay.active .modal { transform:translateY(0) scale(1); }
.modal-header { padding:18px 22px; border-bottom:1px solid var(--line); display:flex; align-items:center; justify-content:space-between; }
.modal-header h3 { font-size:17px; font-weight:800; color:var(--ink); margin:0; }
.modal-close { background:none; border:none; color:#9aa8ba; cursor:pointer; padding:4px; border-radius:8px; line-height:1; font-size:22px; }
.modal-close:hover { color:var(--ink); }
.modal-body { padding:22px; }
.modal-footer { padding:14px 22px; border-top:1px solid var(--line); display:flex; justify-content:flex-end; gap:10px; }
/* ── Form (dentro le modali) ────────────────────────────── */
.form-group { margin-bottom:14px; }
.form-label { display:block; font-weight:600; font-size:12.5px; margin-bottom:5px; color:var(--ink); }
.form-input, .form-select { width:100%; padding:9px 11px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:13.5px; background:#fff; color:var(--ink); }
.form-input:focus, .form-select:focus { outline:2px solid var(--gold); outline-offset:1px; }
/* ── Bottoni extra (btn-secondary/btn-sm) usati dalla JS inline ── */
.btn-secondary { display:inline-flex; align-items:center; gap:7px; border:1px solid var(--line); background:#fff; border-radius:8px; padding:8px 13px; font:inherit; font-weight:600; font-size:12.5px; color:var(--ink); cursor:pointer; }
.btn-secondary:hover { background:#F5F8FC; border-color:#c7d4e4; }
.btn-sm { padding:6px 11px; font-size:12px; }
/* ── Notifiche (prodotte da common.js showNotification — style.css rimosso) ── */
.notification-container { position:fixed; top:20px; right:20px; z-index:2000; display:flex; flex-direction:column; gap:10px; }
.notification { display:flex; align-items:center; gap:10px; background:#fff; border:1px solid var(--line); border-left:4px solid var(--c-blue); border-radius:10px; box-shadow:0 8px 24px rgba(16,40,80,.16); padding:12px 14px; min-width:260px; max-width:380px; font-size:13px; transition:opacity .3s,transform .3s; }
.notification.success { border-left-color:var(--c-green); }
.notification.error { border-left-color:var(--c-red); }
.notification.warning { border-left-color:var(--c-amber); }
.notification.fade-out { opacity:0; transform:translateX(12px); }
.notification-icon svg { width:18px; height:18px; }
.notification.success .notification-icon { color:var(--c-green); }
.notification.error .notification-icon { color:var(--c-red); }
.notification.warning .notification-icon { color:var(--c-amber); }
.notification.info .notification-icon { color:var(--c-blue); }
.notification-message { flex:1; min-width:0; color:var(--ink); }
.notification-close { background:none; border:none; font-size:18px; line-height:1; cursor:pointer; color:#9aa8ba; }
@media (max-width: 768px) {
.courses-grid { grid-template-columns:1fr; }
.tab-nav button { padding:10px 14px; font-size:12.5px; }
.data-table { font-size:12.5px; }
}
</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 id="topnav"></div>
<main class="wrap">
<nav class="crumb" aria-label="breadcrumb">
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<i data-lucide="chevron-right" class="ic"></i><span>Struttura interna</span>
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="training.title">Formazione</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="training.title">Formazione e Awareness</h1>
<p class="sub">Piani formativi, assegnazioni e compliance — Art. 20 NIS2</p>
</div>
<button class="btn-primary" onclick="showCreateCourseModal()"><i data-lucide="plus" class="ic"></i>Nuovo Corso</button>
</div>
<div class="stack">
<section class="card card-pad">
<!-- Tab Navigation -->
<div class="tab-nav">
<button class="active" onclick="switchTab('courses', this)">Corsi</button>
<button onclick="switchTab('assignments', this)">Le Mie Assegnazioni</button>
<button onclick="switchTab('compliance', this)">Compliance Formativa</button>
</div>
<!-- Tab: Corsi -->
<div class="tab-panel active" id="tab-courses">
<div class="courses-grid" id="courses-grid">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento corsi...</p>
</div>
</div>
</div>
<!-- Tab: Le Mie Assegnazioni -->
<div class="tab-panel" id="tab-assignments">
<div id="assignments-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento assegnazioni...</p>
</div>
</div>
</div>
<!-- Tab: Compliance Formativa -->
<div class="tab-panel" id="tab-compliance">
<div style="display:flex; justify-content:flex-end; margin-bottom:16px;">
<button class="btn-primary" onclick="showAssignCourseModal()"><i data-lucide="user-plus" class="ic"></i>Assegna Corso</button>
</div>
<div id="compliance-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento dati compliance...</p>
</div>
</div>
</div>
</section>
</div>
</main>
<!-- Scripts V3: api.js → common.js (helper+FAB reali+showModal/showNotification) →
Lucide self-host → topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help.
La logica della pagina e' inline (nessun training.js): preservata invariata. -->
<script src="/js/api.js?v=20260627u"></script>
<script src="/js/common.js?v=20260627t"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260627t"></script>
<script src="/js/i18n.js?v=20260627p"></script>
<script src="/js/help.js?v=20260627n"></script>
<script>
// ── Auth & Init (ordine V3) ─────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); // = loadTopnavV3() (topnav-v3.js)
if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
function v3Icons() { if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} } }
v3Icons();
// ── Labels ──────────────────────────────────────────────
const roleLabels = {
all: 'Tutti',
board_member: 'Organi di Gestione',
compliance_manager: 'Compliance Manager',
employee: 'Dipendente',
technical: 'Tecnico'
};
const statusLabels = {
assigned: 'Assegnato',
in_progress: 'In Corso',
completed: 'Completato',
overdue: 'Scaduto'
};
// ── State ───────────────────────────────────────────────
let allCourses = [];
let allAssignments = [];
let allCompliance = [];
// ── Tab Switch ──────────────────────────────────────────
function switchTab(tabId, btn) {
document.querySelectorAll('.tab-nav button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + tabId).classList.add('active');
if (tabId === 'courses' && allCourses.length === 0) loadCourses();
if (tabId === 'assignments') loadAssignments();
if (tabId === 'compliance') loadCompliance();
}
// ── Load Courses ────────────────────────────────────────
async function loadCourses() {
const container = document.getElementById('courses-grid');
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento corsi...</p></div>';
try {
const result = await api.listCourses();
if (result.success && result.data) {
allCourses = result.data;
renderCourses(allCourses);
} else {
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4><p>' + escapeHtml(result.message || '') + '</p></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4><p>Impossibile caricare i corsi.</p></div>';
}
}
function renderCourses(courses) {
const container = document.getElementById('courses-grid');
if (!courses || courses.length === 0) {
container.innerHTML = `
<div class="empty-state-box" style="grid-column: 1/-1;">
<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-3z"/></svg>
<h4>Nessun corso disponibile</h4>
<p>Crea il primo corso di formazione per iniziare.</p>
</div>`;
return;
}
let html = '';
courses.forEach(course => {
const mandatory = course.is_mandatory == 1;
html += `
<div class="course-card">
<div class="course-card-header">
<h4 class="course-card-title">${escapeHtml(course.title)}</h4>
${mandatory ? '<span class="badge-mandatory">Obbligatorio</span>' : ''}
</div>
<p class="course-card-desc">${escapeHtml(course.description || 'Nessuna descrizione disponibile.')}</p>
<div class="course-card-meta">
${course.duration_minutes ? `
<span class="course-meta-item">
<svg viewBox="0 0 20 20" fill="currentColor"><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>
${course.duration_minutes} min
</span>
` : ''}
<span class="course-meta-item">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/></svg>
${roleLabels[course.target_role] || course.target_role || 'Tutti'}
</span>
${course.nis2_article ? `
<span class="course-meta-item">
<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-2V4z" clip-rule="evenodd"/></svg>
Art. ${escapeHtml(course.nis2_article)}
</span>
` : ''}
${course.passing_score ? `
<span class="course-meta-item">
Soglia: ${course.passing_score}%
</span>
` : ''}
</div>
</div>`;
});
container.innerHTML = html;
}
// ── Create Course Modal ─────────────────────────────────
function showCreateCourseModal() {
const roleOptions = Object.entries(roleLabels).map(([val, label]) =>
`<option value="${val}">${label}</option>`
).join('');
showModal('Nuovo Corso di Formazione', `
<div class="form-group">
<label class="form-label">Titolo *</label>
<input type="text" class="form-input" id="course-title" placeholder="Es: Fondamenti Cybersecurity NIS2" required>
</div>
<div class="form-group">
<label class="form-label">Descrizione</label>
<textarea class="form-input" id="course-description" rows="3" placeholder="Descrizione del corso..."></textarea>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<div class="form-group">
<label class="form-label">Ruolo Destinatario</label>
<select class="form-select" id="course-target-role">${roleOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Durata (minuti)</label>
<input type="number" class="form-input" id="course-duration" min="1" placeholder="60">
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<div class="form-group">
<label class="form-label">Articolo NIS2</label>
<input type="text" class="form-input" id="course-nis2-article" placeholder="Es: Art. 20">
</div>
<div class="form-group">
<label class="form-label">Punteggio Minimo (%)</label>
<input type="number" class="form-input" id="course-passing-score" min="0" max="100" value="70">
</div>
</div>
<div class="form-group" style="margin-top:8px;">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer;">
<input type="checkbox" id="course-mandatory">
<span class="form-label" style="margin:0;">Corso obbligatorio</span>
</label>
</div>
`, {
size: 'lg',
footer: `
<button class="btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn-primary" onclick="createCourse()">Crea Corso</button>
`
});
}
async function createCourse() {
const title = document.getElementById('course-title').value.trim();
if (!title) {
showNotification('Il titolo e\' obbligatorio.', 'warning');
return;
}
const data = {
title: title,
description: document.getElementById('course-description').value.trim(),
target_role: document.getElementById('course-target-role').value,
is_mandatory: document.getElementById('course-mandatory').checked ? 1 : 0,
duration_minutes: parseInt(document.getElementById('course-duration').value) || null,
nis2_article: document.getElementById('course-nis2-article').value.trim() || null,
passing_score: parseInt(document.getElementById('course-passing-score').value) || 70,
};
closeModal();
showNotification('Creazione corso in corso...', 'info');
try {
const result = await api.post('/training/courses', data);
if (result.success) {
showNotification('Corso creato con successo!', 'success');
loadCourses();
} else {
showNotification(result.message || 'Errore nella creazione del corso.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Load Assignments ────────────────────────────────────
async function loadAssignments() {
const container = document.getElementById('assignments-container');
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento assegnazioni...</p></div>';
try {
const result = await api.getMyTraining();
if (result.success && result.data) {
allAssignments = result.data;
renderAssignments(allAssignments);
} else {
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4></div>';
}
}
function renderAssignments(assignments) {
const container = document.getElementById('assignments-container');
if (!assignments || assignments.length === 0) {
container.innerHTML = `
<div class="empty-state-box">
<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-2V5z" clip-rule="evenodd"/></svg>
<h4>Nessuna assegnazione</h4>
<p>Non hai ancora corsi assegnati.</p>
</div>`;
return;
}
let html = `
<table class="data-table">
<thead>
<tr>
<th>Corso</th>
<th>Stato</th>
<th>Scadenza</th>
<th>Punteggio Quiz</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>`;
assignments.forEach(a => {
const isOverdue = a.due_date && new Date(a.due_date) < new Date() && a.status !== 'completed';
const displayStatus = isOverdue ? 'overdue' : a.status;
let actionBtn = '';
if (displayStatus === 'assigned' || displayStatus === 'overdue') {
actionBtn = `<button class="btn-primary btn-sm" onclick="updateAssignment(${a.id}, 'in_progress')">Inizia</button>`;
} else if (displayStatus === 'in_progress') {
actionBtn = `<button class="btn-primary btn-sm" onclick="updateAssignment(${a.id}, 'completed')">Completa</button>`;
} else {
actionBtn = `<span style="color:var(--c-green); font-weight:600; font-size:13px;">Completato</span>`;
}
html += `
<tr>
<td>
<div style="font-weight:600;">${escapeHtml(a.title || '')}</div>
${a.is_mandatory == 1 ? '<span style="font-size:11px; color:var(--c-red);">Obbligatorio</span>' : ''}
</td>
<td><span class="status-badge ${displayStatus}">${statusLabels[displayStatus] || displayStatus}</span></td>
<td>${a.due_date ? formatDate(a.due_date) : '-'}</td>
<td>${a.quiz_score != null ? a.quiz_score + '%' : '-'}</td>
<td>${actionBtn}</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
async function updateAssignment(id, status) {
try {
const result = await api.put(`/training/assignments/${id}`, { status: status });
if (result.success) {
const msg = status === 'in_progress' ? 'Corso iniziato!' : 'Corso completato!';
showNotification(msg, 'success');
loadAssignments();
} else {
showNotification(result.message || 'Errore nell\'aggiornamento.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Load Compliance Status ──────────────────────────────
async function loadCompliance() {
const container = document.getElementById('compliance-container');
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento dati compliance...</p></div>';
try {
const result = await api.getTrainingCompliance();
if (result.success && result.data) {
allCompliance = result.data;
renderCompliance(allCompliance);
} else {
container.innerHTML = '<div class="empty-state-box"><h4>Errore nel caricamento</h4></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4></div>';
}
}
function renderCompliance(members) {
const container = document.getElementById('compliance-container');
if (!members || members.length === 0) {
container.innerHTML = `
<div class="empty-state-box">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/></svg>
<h4>Nessun membro trovato</h4>
<p>Nessun membro dell\'organizzazione trovato.</p>
</div>`;
return;
}
let html = `
<table class="data-table">
<thead>
<tr>
<th>Nome</th>
<th>Email</th>
<th>Ruolo</th>
<th>Corsi Completati</th>
<th>% Compliance Obbligatoria</th>
</tr>
</thead>
<tbody>`;
members.forEach(m => {
const assignments = m.assignments || [];
const completed = assignments.filter(a => a.status === 'completed').length;
const total = assignments.length;
const pct = m.mandatory_compliance != null ? m.mandatory_compliance : 100;
const pctClass = pct >= 80 ? 'high' : pct >= 50 ? 'medium' : 'low';
const barColor = pct >= 80 ? 'var(--c-green)' : pct >= 50 ? 'var(--c-amber)' : 'var(--c-red)';
html += `
<tr>
<td style="font-weight:600;">${escapeHtml(m.full_name || '-')}</td>
<td>${escapeHtml(m.email || '-')}</td>
<td>${escapeHtml(m.role || '-')}</td>
<td>${completed} / ${total}</td>
<td>
<div class="compliance-bar-container">
<div class="compliance-bar">
<div class="compliance-bar-fill" style="width:${pct}%; background:${barColor};"></div>
</div>
<span class="compliance-pct ${pctClass}">${pct}%</span>
</div>
</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
// ── Assign Course Modal ─────────────────────────────────
async function showAssignCourseModal() {
// Load courses if not already loaded
if (allCourses.length === 0) {
try {
const result = await api.listCourses();
if (result.success && result.data) {
allCourses = result.data;
}
} catch (e) { /* ignore */ }
}
if (allCourses.length === 0) {
showNotification('Nessun corso disponibile. Crea prima un corso.', 'warning');
return;
}
const courseOptions = allCourses.map(c =>
`<option value="${c.id}">${escapeHtml(c.title)}</option>`
).join('');
// Load members for selection
let membersHtml = '<p class="text-muted">Caricamento membri...</p>';
if (allCompliance.length > 0) {
membersHtml = allCompliance.map(m => `
<label style="display:flex; align-items:center; gap:8px; padding:6px 0; cursor:pointer;">
<input type="checkbox" class="assign-user-cb" value="${m.id}">
<span>${escapeHtml(m.full_name || m.email)}</span>
<span style="color:var(--muted); font-size:12px;">(${escapeHtml(m.role || '')})</span>
</label>
`).join('');
}
showModal('Assegna Corso', `
<div class="form-group">
<label class="form-label">Corso</label>
<select class="form-select" id="assign-course-id">${courseOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Scadenza</label>
<input type="date" class="form-input" id="assign-due-date">
</div>
<div class="form-group">
<label class="form-label">Seleziona Utenti</label>
<div style="max-height:200px; overflow-y:auto; border:1px solid var(--line); border-radius:8px; padding:8px 12px;">
${membersHtml}
</div>
</div>
`, {
size: 'lg',
footer: `
<button class="btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn-primary" onclick="doAssignCourse()">Assegna</button>
`
});
}
async function doAssignCourse() {
const courseId = document.getElementById('assign-course-id').value;
const dueDate = document.getElementById('assign-due-date').value || null;
const checkboxes = document.querySelectorAll('.assign-user-cb:checked');
const userIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
if (userIds.length === 0) {
showNotification('Seleziona almeno un utente.', 'warning');
return;
}
closeModal();
showNotification('Assegnazione in corso...', 'info');
try {
const result = await api.post('/training/assign', {
course_id: parseInt(courseId),
user_ids: userIds,
due_date: dueDate
});
if (result.success) {
showNotification(result.message || 'Corso assegnato con successo!', 'success');
loadCompliance();
} else {
showNotification(result.message || 'Errore nell\'assegnazione.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Initial Load ────────────────────────────────────────
loadCourses();
</script>
</body>
</html>