nis2-agile/public/training.html
DevEnv nis2-agile 0e78ec24c1 [FIX] i18n funzionante + bug audit.html + help system
- common.js: aggiunto i18nKey a navItems, data-i18n su sezioni e voci
  sidebar → toggle IT/EN ora traduce la navigazione in tempo reale
- Tutte e 10 le pagine HTML: aggiunto data-i18n="*.title" agli h2
  (dashboard, assessment, risks, incidents, policies, supply-chain,
  training, assets, reports, settings)
- FIX BUG: sidebar puntava ad audit.html (inesistente) → corretto
  in reports.html
- HelpSystem: funziona correttamente in tutte le 10 pagine
  (content-header-actions presente, init() chiamato)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 11:17:04 +01:00

730 lines
32 KiB
HTML

<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Formazione - NIS2 Agile</title>
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Tab Navigation ─────────────────────────────────────── */
.tab-nav {
display: flex;
gap: 0;
border-bottom: 2px solid var(--gray-200);
margin-bottom: 24px;
}
.tab-nav button {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
color: var(--gray-500);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.tab-nav button:hover {
color: var(--gray-700);
background: var(--gray-50);
}
.tab-nav button.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.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: 20px;
}
.course-card {
background: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
padding: 20px;
transition: box-shadow var(--transition);
position: relative;
display: flex;
flex-direction: column;
}
.course-card:hover {
box-shadow: var(--card-shadow-hover);
}
.course-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.course-card-title {
font-size: 1rem;
font-weight: 600;
color: var(--gray-800);
margin: 0;
}
.course-card-desc {
font-size: 0.85rem;
color: var(--gray-500);
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: 0.8rem;
}
.course-meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--gray-500);
background: var(--gray-100);
padding: 4px 10px;
border-radius: 20px;
}
.course-meta-item svg { width: 14px; height: 14px; }
.badge-mandatory {
background: var(--danger-bg);
color: var(--danger);
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ── Assignments Table ──────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
background: var(--card-bg);
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: var(--card-shadow);
}
.data-table thead {
background: var(--gray-50);
}
.data-table th {
text-align: left;
padding: 12px 16px;
font-size: 0.8rem;
font-weight: 600;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--gray-200);
}
.data-table td {
padding: 12px 16px;
font-size: 0.875rem;
color: var(--gray-700);
border-bottom: 1px solid var(--gray-100);
}
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td { background: var(--gray-50); }
/* ── Status Badges ──────────────────────────────────────── */
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 500;
gap: 4px;
}
.status-badge.assigned { background: var(--gray-100); color: var(--gray-600); }
.status-badge.in_progress { background: var(--info-bg); color: var(--info); }
.status-badge.completed { background: var(--secondary-bg); color: var(--secondary); }
.status-badge.overdue { background: var(--danger-bg); color: var(--danger); }
/* ── Compliance Percentage Bar ──────────────────────────── */
.compliance-bar-container {
display: flex;
align-items: center;
gap: 10px;
}
.compliance-bar {
flex-grow: 1;
height: 8px;
background: var(--gray-200);
border-radius: 4px;
overflow: hidden;
}
.compliance-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.compliance-pct {
font-size: 0.85rem;
font-weight: 600;
min-width: 45px;
text-align: right;
}
.compliance-pct.high { color: var(--secondary); }
.compliance-pct.medium { color: var(--warning); }
.compliance-pct.low { color: var(--danger); }
/* ── Spinner/Loading ────────────────────────────────────── */
.loading-state {
text-align: center;
padding: 48px 20px;
color: var(--gray-400);
}
.loading-state .spinner {
width: 32px;
height: 32px;
border: 3px solid var(--gray-200);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Empty State ────────────────────────────────────────── */
.empty-state-box {
text-align: center;
padding: 48px 20px;
color: var(--gray-400);
}
.empty-state-box svg {
width: 48px;
height: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.empty-state-box h4 {
color: var(--gray-600);
margin-bottom: 4px;
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.courses-grid { grid-template-columns: 1fr; }
.tab-nav button { padding: 10px 14px; font-size: 0.82rem; }
.data-table { font-size: 0.8rem; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="training.title">Formazione e Awareness</h2>
<div class="content-header-actions">
<span class="text-muted" style="font-size:0.8rem; margin-right:8px;">Art. 20 NIS2</span>
<button class="btn btn-primary" onclick="showCreateCourseModal()">+ Nuovo Corso</button>
</div>
</header>
<div class="content-body">
<!-- 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 btn-primary" onclick="showAssignCourseModal()">Assegna Corso</button>
</div>
<div id="compliance-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento dati compliance...</p>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/js/api.js"></script>
<script src="/js/common.js"></script>
<script src="/js/i18n.js"></script>
<script src="/js/help.js"></script>
<script>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
HelpSystem.init();
// ── 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 btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn 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 btn-primary btn-sm" onclick="updateAssignment(${a.id}, 'in_progress')">Inizia</button>`;
} else if (displayStatus === 'in_progress') {
actionBtn = `<button class="btn btn-primary btn-sm" onclick="updateAssignment(${a.id}, 'completed')">Completa</button>`;
} else {
actionBtn = `<span style="color:var(--secondary); font-weight:500; font-size:0.85rem;">Completato</span>`;
}
html += `
<tr>
<td>
<div style="font-weight:500;">${escapeHtml(a.title || '')}</div>
${a.is_mandatory == 1 ? '<span style="font-size:0.75rem; color:var(--danger);">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(--secondary)' : pct >= 50 ? 'var(--warning)' : 'var(--danger)';
html += `
<tr>
<td style="font-weight:500;">${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(--gray-400); font-size:0.8rem;">(${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(--gray-200); border-radius:var(--border-radius-sm); padding:8px 12px;">
${membersHtml}
</div>
</div>
`, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn 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>