[FEAT] Add all frontend pages - complete UI for NIS2 platform

- risks.html: Risk register with 5x5 matrix heatmap, treatments, AI suggest
- incidents.html: Incident management with NIS2 Art.23 timeline (24h/72h/30d)
- policies.html: Policy management with templates, approval workflow, AI generate
- supply-chain.html: Supplier registry with 10-question security assessment
- training.html: Courses, assignments, compliance status tracking
- assets.html: Asset inventory with dependency mapping
- reports.html: Compliance report, controls, audit log, ISO 27001 mapping
- settings.html: Organization, profile, members, security settings
- admin/index.html: Platform admin dashboard with stats
- admin/organizations.html: Organization management for super_admin
- admin/users.html: User management for super_admin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cristiano Benassati 2026-02-17 18:46:03 +01:00
parent c03d22ea48
commit 73e78ea6b4
11 changed files with 8736 additions and 0 deletions

375
public/admin/index.html Normal file
View File

@ -0,0 +1,375 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pannello Amministrazione - NIS2 Agile</title>
<link rel="stylesheet" href="/nis2/css/style.css">
<style>
.admin-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.admin-stat-card {
background: var(--card-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--card-shadow);
padding: 20px;
transition: box-shadow var(--transition), transform var(--transition);
}
.admin-stat-card:hover {
box-shadow: var(--card-shadow-hover);
transform: translateY(-2px);
}
.admin-stat-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--gray-500);
margin-bottom: 8px;
}
.admin-stat-value {
font-size: 2rem;
font-weight: 800;
color: var(--gray-900);
line-height: 1;
}
.admin-stat-icon {
width: 40px;
height: 40px;
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.admin-stat-icon svg {
width: 20px;
height: 20px;
}
.admin-stat-icon.blue { background: var(--primary-bg); color: var(--primary); }
.admin-stat-icon.green { background: var(--secondary-bg); color: var(--secondary); }
.admin-stat-icon.orange { background: var(--warning-bg); color: #d97706; }
.admin-stat-icon.red { background: var(--danger-bg); color: var(--danger); }
.admin-stat-icon.purple { background: #f3e8ff; color: #7c3aed; }
.chart-container {
padding: 24px;
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 32px;
height: 200px;
padding: 0 20px;
border-bottom: 2px solid var(--gray-200);
}
.bar-chart-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
height: 100%;
justify-content: flex-end;
}
.bar-chart-bar {
width: 60px;
border-radius: 6px 6px 0 0;
transition: height 0.8s ease;
min-height: 4px;
position: relative;
}
.bar-chart-bar.free { background: var(--gray-400); }
.bar-chart-bar.professional { background: var(--primary); }
.bar-chart-bar.enterprise { background: #7c3aed; }
.bar-chart-value {
font-size: 0.875rem;
font-weight: 700;
color: var(--gray-700);
margin-bottom: 8px;
}
.bar-chart-label {
font-size: 0.75rem;
color: var(--gray-500);
margin-top: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.quick-link-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius-lg);
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
color: inherit;
}
.quick-link-card:hover {
border-color: var(--primary);
background: var(--primary-bg);
color: var(--primary);
transform: translateY(-2px);
box-shadow: var(--card-shadow-hover);
}
.quick-link-icon {
width: 48px;
height: 48px;
border-radius: var(--border-radius);
background: var(--primary-bg);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.quick-link-icon svg {
width: 24px;
height: 24px;
}
.quick-link-info h4 {
font-size: 0.9375rem;
font-weight: 600;
color: var(--gray-800);
margin-bottom: 2px;
}
.quick-link-info p {
font-size: 0.8125rem;
color: var(--gray-500);
}
.quick-link-card:hover .quick-link-info h4 {
color: var(--primary);
}
.quick-links-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
@media (max-width: 1024px) {
.admin-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.admin-stats-grid {
grid-template-columns: 1fr;
}
.quick-links-grid {
grid-template-columns: 1fr;
}
.bar-chart {
gap: 16px;
}
.bar-chart-bar {
width: 40px;
}
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2>Pannello Amministrazione</h2>
<div class="content-header-actions">
<span class="badge badge-danger">Super Admin</span>
</div>
</header>
<div class="content-body">
<!-- Stats Row 1 -->
<div class="admin-stats-grid" id="stats-row-1">
<div class="admin-stat-card">
<div class="admin-stat-icon blue">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/></svg>
</div>
<div class="admin-stat-label">Totale Organizzazioni</div>
<div class="admin-stat-value" id="stat-total-orgs">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon green">
<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>
</div>
<div class="admin-stat-label">Organizzazioni Attive</div>
<div class="admin-stat-value" id="stat-active-orgs">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon blue">
<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>
</div>
<div class="admin-stat-label">Totale Utenti</div>
<div class="admin-stat-value" id="stat-total-users">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon green">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"/></svg>
</div>
<div class="admin-stat-label">Utenti Attivi</div>
<div class="admin-stat-value" id="stat-active-users">--</div>
</div>
</div>
<!-- Stats Row 2 -->
<div class="admin-stats-grid" id="stats-row-2">
<div class="admin-stat-card">
<div class="admin-stat-icon purple">
<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>
</div>
<div class="admin-stat-label">Assessment Completati</div>
<div class="admin-stat-value" id="stat-assessments">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon orange">
<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>
</div>
<div class="admin-stat-label">Incidenti Aperti</div>
<div class="admin-stat-value" id="stat-incidents">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon red">
<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>
</div>
<div class="admin-stat-label">Totale Rischi</div>
<div class="admin-stat-value" id="stat-risks">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon purple">
<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>
</div>
<div class="admin-stat-label">Interazioni AI</div>
<div class="admin-stat-value" id="stat-ai">--</div>
</div>
</div>
<!-- Plans Distribution -->
<div class="card mb-24">
<div class="card-header">
<h3>Distribuzione Piani</h3>
</div>
<div class="chart-container">
<div class="bar-chart" id="plans-chart">
<div class="spinner" style="margin:40px auto;"></div>
</div>
</div>
</div>
<!-- Quick Links -->
<div class="card">
<div class="card-header">
<h3>Gestione Piattaforma</h3>
</div>
<div class="card-body">
<div class="quick-links-grid">
<a href="/nis2/admin/organizations.html" class="quick-link-card">
<div class="quick-link-icon">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/></svg>
</div>
<div class="quick-link-info">
<h4>Gestisci Organizzazioni</h4>
<p>Visualizza e gestisci tutte le organizzazioni registrate sulla piattaforma.</p>
</div>
</a>
<a href="/nis2/admin/users.html" class="quick-link-card">
<div class="quick-link-icon">
<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>
</div>
<div class="quick-link-info">
<h4>Gestisci Utenti</h4>
<p>Visualizza e gestisci tutti gli utenti registrati sulla piattaforma.</p>
</div>
</a>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/nis2/js/api.js"></script>
<script src="/nis2/js/common.js"></script>
<script>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
// ── Admin check ──────────────────────────────────────────
(async function checkAdmin() {
try {
const me = await api.getMe();
if (!me.success || !me.data || me.data.role !== 'super_admin') {
showNotification('Accesso non autorizzato. Solo gli amministratori possono accedere a questa pagina.', 'error');
setTimeout(() => { window.location.href = '/nis2/dashboard.html'; }, 1500);
return;
}
loadAdminStats();
} catch (e) {
window.location.href = '/nis2/dashboard.html';
}
})();
// ── Load Stats ───────────────────────────────────────────
async function loadAdminStats() {
try {
const result = await api.request('GET', '/admin/stats');
if (result.success && result.data) {
const d = result.data;
document.getElementById('stat-total-orgs').textContent = d.total_organizations ?? 0;
document.getElementById('stat-active-orgs').textContent = d.active_organizations ?? 0;
document.getElementById('stat-total-users').textContent = d.total_users ?? 0;
document.getElementById('stat-active-users').textContent = d.active_users ?? 0;
document.getElementById('stat-assessments').textContent = d.completed_assessments ?? 0;
document.getElementById('stat-incidents').textContent = d.open_incidents ?? 0;
document.getElementById('stat-risks').textContent = d.total_risks ?? 0;
document.getElementById('stat-ai').textContent = d.ai_interactions ?? 0;
renderPlansChart(d.plans_distribution || []);
} else {
showNotification('Impossibile caricare le statistiche.', 'error');
}
} catch (e) {
console.error('Errore caricamento stats:', e);
showNotification('Errore di connessione.', 'error');
}
}
// ── Plans Chart ──────────────────────────────────────────
function renderPlansChart(distribution) {
const container = document.getElementById('plans-chart');
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };
const plans = { free: 0, professional: 0, enterprise: 0 };
distribution.forEach(p => {
const key = (p.subscription_plan || 'free').toLowerCase();
if (plans.hasOwnProperty(key)) {
plans[key] = parseInt(p.count) || 0;
}
});
const maxVal = Math.max(1, ...Object.values(plans));
let html = '';
for (const [key, count] of Object.entries(plans)) {
const heightPct = (count / maxVal) * 100;
html += `
<div class="bar-chart-item">
<div class="bar-chart-value">${count}</div>
<div class="bar-chart-bar ${key}" style="height:${Math.max(heightPct, 3)}%;"></div>
<div class="bar-chart-label">${planLabels[key]}</div>
</div>`;
}
container.innerHTML = html;
}
</script>
</body>
</html>

View File

@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gestione Organizzazioni - NIS2 Agile</title>
<link rel="stylesheet" href="/nis2/css/style.css">
<style>
.entity-type-badge {
display: inline-flex;
padding: 3px 10px;
border-radius: 100px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.entity-type-badge.essential { background: var(--danger-bg); color: var(--danger); }
.entity-type-badge.important { background: var(--warning-bg); color: #a16207; }
.entity-type-badge.not_applicable { background: var(--gray-100); color: var(--gray-600); }
.plan-badge {
display: inline-flex;
padding: 3px 10px;
border-radius: 100px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.plan-badge.free { background: var(--gray-100); color: var(--gray-600); }
.plan-badge.professional { background: var(--primary-bg); color: var(--primary); }
.plan-badge.enterprise { background: #f3e8ff; color: #7c3aed; }
.status-dot {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
}
.status-dot::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.active::before { background: var(--secondary); }
.status-dot.inactive::before { background: var(--gray-400); }
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid var(--gray-100);
background: var(--gray-50);
}
.pagination-info {
font-size: 0.8125rem;
color: var(--gray-500);
}
.pagination-controls {
display: flex;
gap: 8px;
}
.score-inline {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
font-weight: 600;
}
.score-inline .score-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.admin-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--gray-500);
margin-bottom: 20px;
}
.admin-breadcrumb a {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.admin-breadcrumb a:hover {
text-decoration: underline;
}
.admin-breadcrumb svg {
width: 14px;
height: 14px;
color: var(--gray-400);
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2>Gestione Organizzazioni</h2>
<div class="content-header-actions">
<span class="badge badge-danger">Super Admin</span>
</div>
</header>
<div class="content-body">
<!-- Breadcrumb -->
<div class="admin-breadcrumb">
<a href="/nis2/admin/index.html">Amministrazione</a>
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
<span>Organizzazioni</span>
</div>
<!-- Table -->
<div class="card">
<div class="card-body" style="padding:0;">
<div id="orgs-container">
<div class="spinner" style="margin:60px auto;"></div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/nis2/js/api.js"></script>
<script src="/nis2/js/common.js"></script>
<script>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
let currentPage = 1;
// ── Admin check ──────────────────────────────────────────
(async function checkAdmin() {
try {
const me = await api.getMe();
if (!me.success || !me.data || me.data.role !== 'super_admin') {
showNotification('Accesso non autorizzato.', 'error');
setTimeout(() => { window.location.href = '/nis2/dashboard.html'; }, 1500);
return;
}
loadOrganizations(1);
} catch (e) {
window.location.href = '/nis2/dashboard.html';
}
})();
// ── Mappature ────────────────────────────────────────────
const sectorLabels = {
energy: 'Energia', transport: 'Trasporti', banking: 'Banche',
health: 'Sanita\'', water: 'Acqua', digital_infra: 'Infrastrutture Digitali',
public_admin: 'Pubblica Amministrazione', manufacturing: 'Manifatturiero',
postal: 'Servizi Postali', chemical: 'Chimico', food: 'Alimentare',
waste: 'Rifiuti', ict_services: 'Servizi ICT',
digital_providers: 'Provider Digitali', space: 'Spazio',
research: 'Ricerca', other: 'Altro'
};
const entityTypeLabels = {
essential: 'Essenziale',
important: 'Importante',
not_applicable: 'N/A'
};
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };
// ── Caricamento ──────────────────────────────────────────
async function loadOrganizations(page) {
const container = document.getElementById('orgs-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
currentPage = page;
try {
const result = await api.request('GET', '/admin/organizations?page=' + page);
if (result.success) {
renderOrganizations(result.data || [], result.pagination || {});
} else {
container.innerHTML = `
<div class="empty-state">
<h4>Errore nel caricamento</h4>
<p>${escapeHtml(result.message || 'Errore sconosciuto')}</p>
</div>`;
}
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
}
}
function renderOrganizations(orgs, pagination) {
const container = document.getElementById('orgs-container');
if (!orgs || orgs.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/></svg>
<h4>Nessuna organizzazione trovata</h4>
<p>Le organizzazioni registrate appariranno qui.</p>
</div>`;
return;
}
let html = `
<div class="table-container">
<table>
<thead>
<tr>
<th>Nome</th>
<th>Settore</th>
<th>Tipo Entita'</th>
<th>Membri</th>
<th>Ultimo Score</th>
<th>Piano</th>
<th>Stato</th>
<th>Data Creazione</th>
</tr>
</thead>
<tbody>`;
orgs.forEach(org => {
const entityType = org.entity_type || 'not_applicable';
const sector = sectorLabels[org.sector] || org.sector || '-';
const entityLabel = entityTypeLabels[entityType] || entityType;
const plan = (org.subscription_plan || 'free').toLowerCase();
const planLabel = planLabels[plan] || plan;
const isActive = org.is_active == 1 || org.is_active === true;
const score = org.last_score;
let scoreHtml = '<span class="text-muted">-</span>';
if (score !== null && score !== undefined) {
const scoreColor = getScoreColor(score);
scoreHtml = `
<span class="score-inline">
<span class="score-dot" style="background:${scoreColor}"></span>
${Math.round(score)}%
</span>`;
}
html += `
<tr>
<td><strong>${escapeHtml(org.name || '-')}</strong></td>
<td>${escapeHtml(sector)}</td>
<td><span class="entity-type-badge ${entityType}">${escapeHtml(entityLabel)}</span></td>
<td>${org.member_count ?? '-'}</td>
<td>${scoreHtml}</td>
<td><span class="plan-badge ${plan}">${escapeHtml(planLabel)}</span></td>
<td><span class="status-dot ${isActive ? 'active' : 'inactive'}">${isActive ? 'Attiva' : 'Inattiva'}</span></td>
<td>${formatDate(org.created_at)}</td>
</tr>`;
});
html += '</tbody></table></div>';
// Paginazione
const total = pagination.total || orgs.length;
const perPage = pagination.per_page || 20;
const page = pagination.page || currentPage;
const totalPages = Math.ceil(total / perPage);
const start = ((page - 1) * perPage) + 1;
const end = Math.min(page * perPage, total);
if (totalPages > 1) {
html += `
<div class="pagination">
<div class="pagination-info">
Visualizzati ${start}-${end} di ${total} organizzazioni
</div>
<div class="pagination-controls">
<button class="btn btn-secondary btn-sm" onclick="loadOrganizations(${page - 1})" ${page <= 1 ? 'disabled' : ''}>
&laquo; Precedente
</button>
<button class="btn btn-secondary btn-sm" onclick="loadOrganizations(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
Successivo &raquo;
</button>
</div>
</div>`;
} else if (total > 0) {
html += `
<div class="pagination">
<div class="pagination-info">
Totale: ${total} organizzazion${total === 1 ? 'e' : 'i'}
</div>
<div></div>
</div>`;
}
container.innerHTML = html;
}
</script>
</body>
</html>

336
public/admin/users.html Normal file
View File

@ -0,0 +1,336 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gestione Utenti - NIS2 Agile</title>
<link rel="stylesheet" href="/nis2/css/style.css">
<style>
.role-badge {
display: inline-flex;
padding: 3px 10px;
border-radius: 100px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.role-badge.super_admin { background: var(--danger-bg); color: var(--danger); }
.role-badge.org_admin { background: var(--primary-bg); color: var(--primary); }
.role-badge.compliance_manager { background: var(--secondary-bg); color: #15803d; }
.role-badge.board_member { background: #f3e8ff; color: #7c3aed; }
.role-badge.auditor { background: var(--info-bg); color: var(--primary); }
.role-badge.employee { background: var(--gray-100); color: var(--gray-600); }
.active-toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
}
.active-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
width: 36px;
height: 20px;
background: var(--gray-300);
border-radius: 10px;
transition: background var(--transition-fast);
position: relative;
}
.toggle-slider::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
top: 2px;
left: 2px;
transition: transform var(--transition-fast);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.active-toggle input:checked + .toggle-slider {
background: var(--secondary);
}
.active-toggle input:checked + .toggle-slider::after {
transform: translateX(16px);
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid var(--gray-100);
background: var(--gray-50);
}
.pagination-info {
font-size: 0.8125rem;
color: var(--gray-500);
}
.pagination-controls {
display: flex;
gap: 8px;
}
.org-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.org-tag {
display: inline-flex;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 500;
background: var(--gray-100);
color: var(--gray-600);
border-radius: var(--border-radius-sm);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--gray-500);
margin-bottom: 20px;
}
.admin-breadcrumb a {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.admin-breadcrumb a:hover {
text-decoration: underline;
}
.admin-breadcrumb svg {
width: 14px;
height: 14px;
color: var(--gray-400);
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2>Gestione Utenti</h2>
<div class="content-header-actions">
<span class="badge badge-danger">Super Admin</span>
</div>
</header>
<div class="content-body">
<!-- Breadcrumb -->
<div class="admin-breadcrumb">
<a href="/nis2/admin/index.html">Amministrazione</a>
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
<span>Utenti</span>
</div>
<!-- Table -->
<div class="card">
<div class="card-body" style="padding:0;">
<div id="users-container">
<div class="spinner" style="margin:60px auto;"></div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/nis2/js/api.js"></script>
<script src="/nis2/js/common.js"></script>
<script>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
let currentPage = 1;
// ── Admin check ──────────────────────────────────────────
(async function checkAdmin() {
try {
const me = await api.getMe();
if (!me.success || !me.data || me.data.role !== 'super_admin') {
showNotification('Accesso non autorizzato.', 'error');
setTimeout(() => { window.location.href = '/nis2/dashboard.html'; }, 1500);
return;
}
loadUsers(1);
} catch (e) {
window.location.href = '/nis2/dashboard.html';
}
})();
// ── Mappature ────────────────────────────────────────────
const roleLabels = {
super_admin: 'Super Admin',
org_admin: 'Amministratore',
compliance_manager: 'Compliance Manager',
board_member: 'Membro CDA',
auditor: 'Auditor',
employee: 'Dipendente'
};
// ── Caricamento ──────────────────────────────────────────
async function loadUsers(page) {
const container = document.getElementById('users-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
currentPage = page;
try {
const result = await api.request('GET', '/admin/users?page=' + page);
if (result.success) {
renderUsers(result.data || [], result.pagination || {});
} else {
container.innerHTML = `
<div class="empty-state">
<h4>Errore nel caricamento</h4>
<p>${escapeHtml(result.message || 'Errore sconosciuto')}</p>
</div>`;
}
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
}
}
function renderUsers(users, pagination) {
const container = document.getElementById('users-container');
if (!users || users.length === 0) {
container.innerHTML = `
<div class="empty-state">
<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 utente trovato</h4>
<p>Gli utenti registrati appariranno qui.</p>
</div>`;
return;
}
let html = `
<div class="table-container">
<table>
<thead>
<tr>
<th>Nome</th>
<th>Email</th>
<th>Ruolo</th>
<th>Organizzazioni</th>
<th>Ultimo Login</th>
<th>Attivo</th>
<th>Data Creazione</th>
</tr>
</thead>
<tbody>`;
users.forEach(user => {
const role = user.role || 'employee';
const roleLabel = roleLabels[role] || role;
const isActive = user.is_active == 1 || user.is_active === true;
// Organizzazioni
let orgsHtml = '<span class="text-muted">-</span>';
if (user.organizations) {
const orgNames = user.organizations.split(',').filter(n => n.trim());
if (orgNames.length > 0) {
orgsHtml = '<div class="org-tags">';
orgNames.forEach(name => {
orgsHtml += `<span class="org-tag" title="${escapeHtml(name.trim())}">${escapeHtml(name.trim())}</span>`;
});
orgsHtml += '</div>';
}
}
// Ultimo login
const lastLogin = user.last_login_at ? formatDateTime(user.last_login_at) : '<span class="text-muted">Mai</span>';
html += `
<tr>
<td><strong>${escapeHtml(user.full_name || '-')}</strong></td>
<td>${escapeHtml(user.email || '-')}</td>
<td><span class="role-badge ${role}">${escapeHtml(roleLabel)}</span></td>
<td>${orgsHtml}</td>
<td>${lastLogin}</td>
<td>
<label class="active-toggle" title="${isActive ? 'Attivo' : 'Inattivo'}">
<input type="checkbox" ${isActive ? 'checked' : ''} onchange="toggleUserActive(${user.id}, this.checked)" ${role === 'super_admin' ? 'disabled' : ''}>
<span class="toggle-slider"></span>
</label>
</td>
<td>${formatDate(user.created_at)}</td>
</tr>`;
});
html += '</tbody></table></div>';
// Paginazione
const total = pagination.total || users.length;
const perPage = pagination.per_page || 20;
const page = pagination.page || currentPage;
const totalPages = Math.ceil(total / perPage);
const start = ((page - 1) * perPage) + 1;
const end = Math.min(page * perPage, total);
if (totalPages > 1) {
html += `
<div class="pagination">
<div class="pagination-info">
Visualizzati ${start}-${end} di ${total} utenti
</div>
<div class="pagination-controls">
<button class="btn btn-secondary btn-sm" onclick="loadUsers(${page - 1})" ${page <= 1 ? 'disabled' : ''}>
&laquo; Precedente
</button>
<button class="btn btn-secondary btn-sm" onclick="loadUsers(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
Successivo &raquo;
</button>
</div>
</div>`;
} else if (total > 0) {
html += `
<div class="pagination">
<div class="pagination-info">
Totale: ${total} utent${total === 1 ? 'e' : 'i'}
</div>
<div></div>
</div>`;
}
container.innerHTML = html;
}
// ── Toggle Attivo/Inattivo ───────────────────────────────
async function toggleUserActive(userId, isActive) {
try {
const result = await api.request('PUT', '/admin/users/' + userId, {
is_active: isActive ? 1 : 0
});
if (result.success) {
showNotification(
isActive ? 'Utente attivato con successo.' : 'Utente disattivato con successo.',
'success'
);
} else {
showNotification(result.message || 'Errore nell\'aggiornamento.', 'error');
// Ricarica per resettare lo stato
loadUsers(currentPage);
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
loadUsers(currentPage);
}
}
</script>
</body>
</html>

764
public/assets.html Normal file
View File

@ -0,0 +1,764 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inventario Asset - NIS2 Agile</title>
<link rel="stylesheet" href="/nis2/css/style.css">
<style>
/* ── Stats Cards ────────────────────────────────────────── */
.asset-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.asset-stat-card {
background: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
}
.asset-stat-icon {
width: 48px;
height: 48px;
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.asset-stat-icon svg { width: 24px; height: 24px; }
.asset-stat-icon.primary { background: var(--primary-bg); color: var(--primary); }
.asset-stat-icon.success { background: var(--secondary-bg); color: var(--secondary); }
.asset-stat-icon.danger { background: var(--danger-bg); color: var(--danger); }
.asset-stat-icon.warning { background: var(--warning-bg); color: var(--gray-600); }
.asset-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--gray-800);
line-height: 1.2;
}
.asset-stat-label {
font-size: 0.8rem;
color: var(--gray-500);
}
/* ── Filters Bar ────────────────────────────────────────── */
.filters-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
align-items: center;
}
.filters-bar select {
padding: 8px 12px;
border: 1px solid var(--gray-200);
border-radius: var(--border-radius-sm);
background: var(--card-bg);
color: var(--gray-700);
font-size: 0.85rem;
cursor: pointer;
}
.filters-bar select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-bg);
}
/* ── Data 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 tbody tr { cursor: pointer; transition: background var(--transition-fast); }
.data-table tbody tr:hover td { background: var(--gray-50); }
/* ── Criticality Badges ─────────────────────────────────── */
.criticality-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 500;
}
.criticality-badge.critical { background: var(--danger-bg); color: var(--danger); }
.criticality-badge.high { background: #fff3e0; color: #e65100; }
.criticality-badge.medium { background: var(--warning-bg); color: #b8860b; }
.criticality-badge.low { background: var(--secondary-bg); color: var(--secondary); }
/* ── Status Badge ───────────────────────────────────────── */
.status-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 500;
}
.status-badge.active { background: var(--secondary-bg); color: var(--secondary); }
.status-badge.maintenance { background: var(--warning-bg); color: #b8860b; }
.status-badge.decommissioned { background: var(--gray-100); color: var(--gray-500); }
/* ── Asset Detail Panel ─────────────────────────────────── */
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.detail-field {
margin-bottom: 4px;
}
.detail-field-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.detail-field-value {
font-size: 0.9rem;
color: var(--gray-800);
}
.detail-section-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--gray-700);
margin: 20px 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--gray-200);
}
.dep-tag {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--primary-bg);
color: var(--primary);
padding: 4px 10px;
border-radius: 20px;
font-size: 0.8rem;
margin: 2px 4px 2px 0;
}
/* ── Action Buttons ─────────────────────────────────────── */
.btn-sm {
padding: 5px 12px;
font-size: 0.8rem;
}
.btn-icon {
padding: 6px;
background: none;
border: none;
color: var(--gray-400);
cursor: pointer;
border-radius: var(--border-radius-sm);
transition: all var(--transition-fast);
}
.btn-icon:hover { color: var(--primary); background: var(--primary-bg); }
.btn-icon svg { width: 18px; height: 18px; }
/* ── Type breakdown chips ───────────────────────────────── */
.type-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.type-chip {
background: var(--gray-100);
padding: 3px 10px;
border-radius: 20px;
font-size: 0.75rem;
color: var(--gray-600);
}
.type-chip strong {
color: var(--gray-800);
margin-right: 3px;
}
/* ── Loading/Empty ──────────────────────────────────────── */
.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-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;
}
/* ── Modal form enhancements ───────────────────────────── */
.form-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.asset-stats { grid-template-columns: 1fr 1fr; }
.detail-grid { grid-template-columns: 1fr; }
.filters-bar { flex-direction: column; }
.form-grid-2 { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2>Inventario Asset</h2>
<div class="content-header-actions">
<span class="text-muted" style="font-size:0.8rem; margin-right:8px;">Art. 21.2.i NIS2</span>
<button class="btn btn-primary" onclick="showCreateAssetModal()">+ Nuovo Asset</button>
</div>
</header>
<div class="content-body">
<!-- Stats Cards -->
<div class="asset-stats" id="asset-stats">
<div class="asset-stat-card">
<div class="asset-stat-icon primary">
<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>
</div>
<div>
<div class="asset-stat-value" id="stat-total">--</div>
<div class="asset-stat-label">Totale Asset</div>
</div>
</div>
<div class="asset-stat-card">
<div class="asset-stat-icon success">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
</div>
<div>
<div class="asset-stat-value" id="stat-types">--</div>
<div class="asset-stat-label">Tipologie</div>
<div class="type-chips" id="stat-type-chips"></div>
</div>
</div>
<div class="asset-stat-card">
<div class="asset-stat-icon danger">
<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>
</div>
<div>
<div class="asset-stat-value" id="stat-critical">--</div>
<div class="asset-stat-label">Asset Critici</div>
</div>
</div>
<div class="asset-stat-card">
<div class="asset-stat-icon warning">
<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>
</div>
<div>
<div class="asset-stat-value" id="stat-decommissioned">--</div>
<div class="asset-stat-label">Dismessi</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="filters-bar">
<select id="filter-type" onchange="applyFilters()">
<option value="">Tutti i tipi</option>
<option value="hardware">Hardware</option>
<option value="software">Software</option>
<option value="network">Rete</option>
<option value="data">Dati</option>
<option value="service">Servizio</option>
<option value="personnel">Personale</option>
<option value="facility">Struttura</option>
</select>
<select id="filter-criticality" onchange="applyFilters()">
<option value="">Tutte le criticita'</option>
<option value="critical">Critica</option>
<option value="high">Alta</option>
<option value="medium">Media</option>
<option value="low">Bassa</option>
</select>
<select id="filter-status" onchange="applyFilters()">
<option value="">Tutti gli stati</option>
<option value="active">Attivo</option>
<option value="maintenance">Manutenzione</option>
<option value="decommissioned">Dismesso</option>
</select>
</div>
<!-- Asset Table -->
<div id="assets-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento asset...</p>
</div>
</div>
</div>
</main>
</div>
<script src="/nis2/js/api.js"></script>
<script src="/nis2/js/common.js"></script>
<script>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
// ── Labels ──────────────────────────────────────────────
const typeLabels = {
hardware: 'Hardware',
software: 'Software',
network: 'Rete',
data: 'Dati',
service: 'Servizio',
personnel: 'Personale',
facility: 'Struttura'
};
const criticalityLabels = {
critical: 'Critica',
high: 'Alta',
medium: 'Media',
low: 'Bassa'
};
const statusLabels = {
active: 'Attivo',
maintenance: 'Manutenzione',
decommissioned: 'Dismesso'
};
// ── State ───────────────────────────────────────────────
let allAssets = [];
// ── Load Assets ─────────────────────────────────────────
async function loadAssets() {
const container = document.getElementById('assets-container');
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento asset...</p></div>';
const params = {};
const type = document.getElementById('filter-type').value;
const criticality = document.getElementById('filter-criticality').value;
const status = document.getElementById('filter-status').value;
if (type) params.asset_type = type;
if (criticality) params.criticality = criticality;
if (status) params.status = status;
try {
const result = await api.listAssets(params);
if (result.success) {
allAssets = result.data || [];
updateStats(allAssets);
renderAssets(allAssets);
} 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 gli asset.</p></div>';
}
}
function applyFilters() {
loadAssets();
}
function updateStats(assets) {
// For stats, we count from the current filtered results
// In a full implementation, stats would come from the server unfiltered
document.getElementById('stat-total').textContent = assets.length;
// Type breakdown
const typeCount = {};
let criticalCount = 0;
let decommCount = 0;
assets.forEach(a => {
const t = a.asset_type || 'unknown';
typeCount[t] = (typeCount[t] || 0) + 1;
if (a.criticality === 'critical') criticalCount++;
if (a.status === 'decommissioned') decommCount++;
});
const uniqueTypes = Object.keys(typeCount).length;
document.getElementById('stat-types').textContent = uniqueTypes;
document.getElementById('stat-critical').textContent = criticalCount;
document.getElementById('stat-decommissioned').textContent = decommCount;
// Type chips
const chipsEl = document.getElementById('stat-type-chips');
chipsEl.innerHTML = Object.entries(typeCount).map(([type, count]) =>
`<span class="type-chip"><strong>${count}</strong> ${typeLabels[type] || type}</span>`
).join('');
}
function renderAssets(assets) {
const container = document.getElementById('assets-container');
if (!assets || assets.length === 0) {
container.innerHTML = `
<div class="empty-state-box">
<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>
<h4>Nessun asset trovato</h4>
<p>Registra il primo asset per costruire l'inventario.</p>
</div>`;
return;
}
let html = `
<table class="data-table">
<thead>
<tr>
<th>Nome</th>
<th>Tipo</th>
<th>Categoria</th>
<th>Criticita'</th>
<th>Owner</th>
<th>Stato</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>`;
assets.forEach(asset => {
const crit = asset.criticality || 'medium';
const st = asset.status || 'active';
html += `
<tr onclick="showAssetDetail(${asset.id})" title="Visualizza dettagli">
<td style="font-weight:500;">${escapeHtml(asset.name)}</td>
<td>${typeLabels[asset.asset_type] || asset.asset_type || '-'}</td>
<td>${escapeHtml(asset.category || '-')}</td>
<td><span class="criticality-badge ${crit}">${criticalityLabels[crit] || crit}</span></td>
<td>${escapeHtml(asset.owner_name || '-')}</td>
<td><span class="status-badge ${st}">${statusLabels[st] || st}</span></td>
<td>
<button class="btn-icon" onclick="event.stopPropagation(); showEditAssetModal(${asset.id})" title="Modifica">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
</button>
</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
// ── Asset Detail View ───────────────────────────────────
async function showAssetDetail(id) {
try {
const result = await api.getAsset(id);
if (!result.success || !result.data) {
showNotification('Asset non trovato.', 'error');
return;
}
const a = result.data;
const crit = a.criticality || 'medium';
const st = a.status || 'active';
let depsHtml = '<span style="color:var(--gray-400);">Nessuna dipendenza</span>';
if (a.dependencies) {
let deps = a.dependencies;
if (typeof deps === 'string') {
try { deps = JSON.parse(deps); } catch(e) { deps = []; }
}
if (deps && deps.length > 0) {
depsHtml = deps.map(d => `<span class="dep-tag">ID: ${escapeHtml(String(d))}</span>`).join('');
}
}
showModal(escapeHtml(a.name), `
<div class="detail-grid">
<div class="detail-field">
<div class="detail-field-label">Tipo</div>
<div class="detail-field-value">${typeLabels[a.asset_type] || a.asset_type || '-'}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Categoria</div>
<div class="detail-field-value">${escapeHtml(a.category || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Criticita'</div>
<div class="detail-field-value"><span class="criticality-badge ${crit}">${criticalityLabels[crit] || crit}</span></div>
</div>
<div class="detail-field">
<div class="detail-field-label">Stato</div>
<div class="detail-field-value"><span class="status-badge ${st}">${statusLabels[st] || st}</span></div>
</div>
<div class="detail-field">
<div class="detail-field-label">Owner</div>
<div class="detail-field-value">${escapeHtml(a.owner_name || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Posizione</div>
<div class="detail-field-value">${escapeHtml(a.location || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Indirizzo IP</div>
<div class="detail-field-value">${escapeHtml(a.ip_address || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Fornitore</div>
<div class="detail-field-value">${escapeHtml(a.vendor || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Versione</div>
<div class="detail-field-value">${escapeHtml(a.version || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Numero Seriale</div>
<div class="detail-field-value">${escapeHtml(a.serial_number || '-')}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Data Acquisto</div>
<div class="detail-field-value">${formatDate(a.purchase_date)}</div>
</div>
<div class="detail-field">
<div class="detail-field-label">Scadenza Garanzia</div>
<div class="detail-field-value">${formatDate(a.warranty_expiry)}</div>
</div>
</div>
${a.description ? `
<div class="detail-section-title">Descrizione</div>
<p style="font-size:0.9rem; color:var(--gray-600);">${escapeHtml(a.description)}</p>
` : ''}
<div class="detail-section-title">Dipendenze</div>
<div>${depsHtml}</div>
`, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>
<button class="btn btn-primary" onclick="closeModal(); showEditAssetModal(${a.id})">Modifica</button>
`
});
} catch (e) {
showNotification('Errore nel caricamento dell\'asset.', 'error');
}
}
// ── Create Asset Modal ──────────────────────────────────
function showCreateAssetModal() {
showAssetModal('Nuovo Asset', {}, async function(data) {
try {
const result = await api.createAsset(data);
if (result.success) {
showNotification('Asset registrato con successo!', 'success');
closeModal();
loadAssets();
} else {
showNotification(result.message || 'Errore nella creazione.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
});
}
// ── Edit Asset Modal ────────────────────────────────────
async function showEditAssetModal(id) {
try {
const result = await api.getAsset(id);
if (!result.success || !result.data) {
showNotification('Asset non trovato.', 'error');
return;
}
showAssetModal('Modifica Asset', result.data, async function(data) {
try {
const res = await api.updateAsset(id, data);
if (res.success) {
showNotification('Asset aggiornato con successo!', 'success');
closeModal();
loadAssets();
} else {
showNotification(res.message || 'Errore nell\'aggiornamento.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
});
} catch (e) {
showNotification('Errore nel caricamento.', 'error');
}
}
// ── Generic Asset Modal ─────────────────────────────────
function showAssetModal(title, asset, onSave) {
const a = asset || {};
const typeOptions = Object.entries(typeLabels).map(([val, label]) =>
`<option value="${val}" ${a.asset_type === val ? 'selected' : ''}>${label}</option>`
).join('');
const critOptions = Object.entries(criticalityLabels).map(([val, label]) =>
`<option value="${val}" ${a.criticality === val ? 'selected' : ''}>${label}</option>`
).join('');
const statusOptions = Object.entries(statusLabels).map(([val, label]) =>
`<option value="${val}" ${a.status === val ? 'selected' : ''}>${label}</option>`
).join('');
// Store the callback
window._assetSaveCallback = onSave;
showModal(title, `
<div class="form-group">
<label class="form-label">Nome *</label>
<input type="text" class="form-input" id="asset-name" value="${escapeHtml(a.name || '')}" required>
</div>
<div class="form-grid-2">
<div class="form-group">
<label class="form-label">Tipo *</label>
<select class="form-select" id="asset-type">${typeOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Categoria</label>
<input type="text" class="form-input" id="asset-category" value="${escapeHtml(a.category || '')}" placeholder="Es: Server, Database...">
</div>
</div>
<div class="form-group">
<label class="form-label">Descrizione</label>
<textarea class="form-input" id="asset-description" rows="2">${escapeHtml(a.description || '')}</textarea>
</div>
<div class="form-grid-2">
<div class="form-group">
<label class="form-label">Criticita'</label>
<select class="form-select" id="asset-criticality">${critOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Stato</label>
<select class="form-select" id="asset-status">${statusOptions}</select>
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label class="form-label">Owner (ID utente)</label>
<input type="text" class="form-input" id="asset-owner" value="${escapeHtml(a.owner_user_id || '')}">
</div>
<div class="form-group">
<label class="form-label">Posizione</label>
<input type="text" class="form-input" id="asset-location" value="${escapeHtml(a.location || '')}" placeholder="Es: Data Center Roma">
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label class="form-label">Indirizzo IP</label>
<input type="text" class="form-input" id="asset-ip" value="${escapeHtml(a.ip_address || '')}" placeholder="Es: 192.168.1.100">
</div>
<div class="form-group">
<label class="form-label">Fornitore</label>
<input type="text" class="form-input" id="asset-vendor" value="${escapeHtml(a.vendor || '')}">
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label class="form-label">Versione</label>
<input type="text" class="form-input" id="asset-version" value="${escapeHtml(a.version || '')}">
</div>
<div class="form-group">
<label class="form-label">Numero Seriale</label>
<input type="text" class="form-input" id="asset-serial" value="${escapeHtml(a.serial_number || '')}">
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label class="form-label">Data Acquisto</label>
<input type="date" class="form-input" id="asset-purchase-date" value="${a.purchase_date || ''}">
</div>
<div class="form-group">
<label class="form-label">Scadenza Garanzia</label>
<input type="date" class="form-input" id="asset-warranty" value="${a.warranty_expiry || ''}">
</div>
</div>
`, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="saveAsset()">Salva</button>
`
});
}
function saveAsset() {
const name = document.getElementById('asset-name').value.trim();
if (!name) {
showNotification('Il nome e\' obbligatorio.', 'warning');
return;
}
const data = {
name: name,
asset_type: document.getElementById('asset-type').value,
category: document.getElementById('asset-category').value.trim() || null,
description: document.getElementById('asset-description').value.trim() || null,
criticality: document.getElementById('asset-criticality').value,
owner_user_id: document.getElementById('asset-owner').value.trim() || null,
location: document.getElementById('asset-location').value.trim() || null,
ip_address: document.getElementById('asset-ip').value.trim() || null,
vendor: document.getElementById('asset-vendor').value.trim() || null,
version: document.getElementById('asset-version').value.trim() || null,
serial_number: document.getElementById('asset-serial').value.trim() || null,
purchase_date: document.getElementById('asset-purchase-date').value || null,
warranty_expiry: document.getElementById('asset-warranty').value || null,
status: document.getElementById('asset-status').value,
};
if (window._assetSaveCallback) {
window._assetSaveCallback(data);
}
}
// ── Initial Load ────────────────────────────────────────
loadAssets();
</script>
</body>
</html>

1148
public/incidents.html Normal file

File diff suppressed because it is too large Load Diff

930
public/policies.html Normal file
View File

@ -0,0 +1,930 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Policy - NIS2 Agile</title>
<link rel="stylesheet" href="/nis2/css/style.css">
<style>
/* ── Policy Page Styles ──────────────────────────────────── */
.filters-bar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 20px;
}
.filters-bar .form-select {
min-width: 180px;
padding: 8px 12px;
font-size: 0.85rem;
}
.filters-bar .btn {
white-space: nowrap;
}
.policy-actions {
display: flex;
gap: 6px;
}
.policy-actions .btn {
padding: 5px 10px;
font-size: 0.75rem;
}
.tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--gray-200);
margin-bottom: 24px;
}
.tab-btn {
padding: 10px 20px;
font-size: 0.875rem;
font-weight: 600;
color: var(--gray-500);
background: none;
border: none;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all var(--transition-fast);
}
.tab-btn:hover {
color: var(--gray-700);
}
.tab-btn.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.template-card {
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius);
padding: 20px;
cursor: pointer;
transition: all var(--transition-fast);
}
.template-card:hover {
border-color: var(--primary);
box-shadow: var(--card-shadow-hover);
transform: translateY(-2px);
}
.template-card h4 {
font-size: 0.9rem;
color: var(--gray-800);
margin-bottom: 8px;
}
.template-card .template-meta {
font-size: 0.78rem;
color: var(--gray-500);
display: flex;
align-items: center;
gap: 12px;
}
.template-card .template-meta .tag {
font-size: 0.7rem;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
gap: 16px;
}
.detail-header h3 {
font-size: 1.25rem;
color: var(--gray-900);
}
.detail-header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 24px;
}
@media (max-width: 900px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
.policy-content-box {
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius);
padding: 24px;
white-space: pre-wrap;
font-size: 0.875rem;
line-height: 1.7;
color: var(--gray-700);
min-height: 200px;
max-height: 600px;
overflow-y: auto;
}
.meta-card {
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius);
padding: 20px;
}
.meta-card h4 {
font-size: 0.85rem;
font-weight: 600;
color: var(--gray-800);
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--gray-100);
}
.meta-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 0.82rem;
border-bottom: 1px solid var(--gray-50);
}
.meta-row:last-child {
border-bottom: none;
}
.meta-label {
color: var(--gray-500);
font-weight: 500;
}
.meta-value {
color: var(--gray-800);
font-weight: 600;
text-align: right;
}
.ai-preview {
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius);
padding: 20px;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
font-size: 0.85rem;
line-height: 1.6;
margin-top: 12px;
}
.ai-preview-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: var(--primary);
font-weight: 600;
font-size: 0.9rem;
}
.ai-preview-header svg {
width: 20px;
height: 20px;
}
.loading-overlay {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 20px;
flex-direction: column;
gap: 12px;
color: var(--gray-500);
}
.loading-overlay .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;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.text-link {
color: var(--primary);
cursor: pointer;
font-weight: 500;
}
.text-link:hover {
text-decoration: underline;
}
.content-header-actions {
display: flex;
gap: 8px;
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2>Policy</h2>
<div class="content-header-actions">
<button class="btn btn-outline" onclick="openAIGenerateModal()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><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>
Genera con AI
</button>
<button class="btn btn-primary" onclick="openCreateModal()">+ Nuova Policy</button>
</div>
</header>
<div class="content-body" id="page-content">
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('list')">Elenco Policy</button>
<button class="tab-btn" onclick="switchTab('templates')">Template</button>
</div>
<!-- Tab: Lista Policy -->
<div class="tab-content active" id="tab-list">
<div class="filters-bar">
<select class="form-select" id="filter-category" onchange="loadPolicies()">
<option value="">Tutte le Categorie</option>
<option value="information_security">Sicurezza Informazioni</option>
<option value="access_control">Controllo Accessi</option>
<option value="incident_response">Risposta Incidenti</option>
<option value="business_continuity">Continuita' Operativa</option>
<option value="supply_chain">Supply Chain</option>
<option value="encryption">Crittografia</option>
<option value="hr_security">Sicurezza HR</option>
<option value="asset_management">Gestione Asset</option>
<option value="network_security">Sicurezza Rete</option>
<option value="vulnerability_management">Gestione Vulnerabilita'</option>
</select>
<select class="form-select" id="filter-status" onchange="loadPolicies()">
<option value="">Tutti gli Stati</option>
<option value="draft">Bozza</option>
<option value="review">In Revisione</option>
<option value="approved">Approvata</option>
<option value="published">Pubblicata</option>
<option value="archived">Archiviata</option>
</select>
</div>
<div class="card">
<div class="table-container" id="policies-table">
<div class="loading-overlay">
<div class="spinner"></div>
<span>Caricamento policy...</span>
</div>
</div>
</div>
</div>
<!-- Tab: Template -->
<div class="tab-content" id="tab-templates">
<div class="card">
<div class="card-header">
<h3>Template Policy NIS2</h3>
</div>
<div class="card-body" id="templates-grid">
<div class="loading-overlay">
<div class="spinner"></div>
<span>Caricamento template...</span>
</div>
</div>
</div>
</div>
<!-- Detail View (hidden by default) -->
<div id="policy-detail-view" style="display:none;"></div>
</div>
</main>
</div>
<script src="/nis2/js/api.js"></script>
<script src="/nis2/js/common.js"></script>
<script>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
// ── Labels ──────────────────────────────────────────────
const CATEGORY_LABELS = {
information_security: 'Sicurezza Informazioni',
access_control: 'Controllo Accessi',
incident_response: 'Risposta Incidenti',
business_continuity: 'Continuita\' Operativa',
supply_chain: 'Supply Chain',
encryption: 'Crittografia',
hr_security: 'Sicurezza HR',
asset_management: 'Gestione Asset',
network_security: 'Sicurezza Rete',
vulnerability_management: 'Gestione Vulnerabilita\''
};
const STATUS_LABELS = {
draft: 'Bozza',
review: 'In Revisione',
approved: 'Approvata',
published: 'Pubblicata',
archived: 'Archiviata'
};
const STATUS_BADGE_CLASS = {
draft: 'badge-neutral',
review: 'badge-info',
approved: 'badge-success',
published: 'badge-primary',
archived: 'badge-neutral'
};
// ── State ───────────────────────────────────────────────
let currentView = 'list'; // 'list' | 'detail'
let policiesData = [];
// ── Tab switching ───────────────────────────────────────
function switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
if (tab === 'list') {
document.querySelectorAll('.tab-btn')[0].classList.add('active');
document.getElementById('tab-list').classList.add('active');
} else if (tab === 'templates') {
document.querySelectorAll('.tab-btn')[1].classList.add('active');
document.getElementById('tab-templates').classList.add('active');
loadTemplates();
}
// Hide detail view when switching tabs
document.getElementById('policy-detail-view').style.display = 'none';
document.querySelector('.tabs').style.display = '';
document.getElementById('tab-list').style.display = '';
document.getElementById('tab-templates').style.display = '';
currentView = 'list';
}
// ── Load Policies ───────────────────────────────────────
async function loadPolicies() {
const params = {};
const category = document.getElementById('filter-category').value;
const status = document.getElementById('filter-status').value;
if (category) params.category = category;
if (status) params.status = status;
const container = document.getElementById('policies-table');
container.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento policy...</span></div>';
try {
const result = await api.listPolicies(params);
if (result.success) {
policiesData = result.data || [];
renderPoliciesTable(policiesData);
} else {
container.innerHTML = '<div class="empty-state"><h4>Errore nel caricamento</h4><p>' + escapeHtml(result.message || 'Errore sconosciuto') + '</p></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4><p>Impossibile caricare le policy.</p></div>';
}
}
function renderPoliciesTable(policies) {
const container = document.getElementById('policies-table');
if (!policies || policies.length === 0) {
container.innerHTML = `
<div class="empty-state" style="padding:40px 20px;">
<svg viewBox="0 0 20 20" fill="currentColor" style="width:48px;height:48px;color:var(--gray-300);">
<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>
<h4>Nessuna policy trovata</h4>
<p>Crea la tua prima policy o utilizza un template per iniziare.</p>
</div>`;
return;
}
let html = `
<table>
<thead>
<tr>
<th>Titolo</th>
<th>Categoria</th>
<th>Art. NIS2</th>
<th>Versione</th>
<th>Stato</th>
<th>Prossima Revisione</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>`;
policies.forEach(p => {
const catLabel = CATEGORY_LABELS[p.category] || p.category || '-';
const statusLabel = STATUS_LABELS[p.status] || p.status || 'Bozza';
const badgeClass = STATUS_BADGE_CLASS[p.status] || 'badge-neutral';
html += `
<tr>
<td>
<span class="text-link" onclick="viewPolicy(${p.id})">${escapeHtml(p.title)}</span>
</td>
<td>${escapeHtml(catLabel)}</td>
<td>${escapeHtml(p.nis2_article || '-')}</td>
<td>${escapeHtml(p.version || '1.0')}</td>
<td><span class="badge ${badgeClass}">${escapeHtml(statusLabel)}</span></td>
<td>${formatDate(p.next_review_date)}</td>
<td>
<div class="policy-actions">
<button class="btn btn-sm btn-ghost" onclick="viewPolicy(${p.id})" title="Visualizza">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
</button>
<button class="btn btn-sm btn-ghost" onclick="openEditModal(${p.id})" title="Modifica">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
</button>
${p.status === 'draft' || p.status === 'review' ? `
<button class="btn btn-sm btn-success" onclick="approvePolicy(${p.id})" title="Approva">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</button>` : ''}
<button class="btn btn-sm btn-ghost" onclick="confirmDeletePolicy(${p.id}, '${escapeHtml(p.title).replace(/'/g, "\\'")}')" title="Elimina" style="color:var(--danger);">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
</button>
</div>
</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
// ── View Policy Detail ──────────────────────────────────
async function viewPolicy(id) {
// Hide tabs and list
document.querySelector('.tabs').style.display = 'none';
document.getElementById('tab-list').classList.remove('active');
document.getElementById('tab-templates').classList.remove('active');
const detailView = document.getElementById('policy-detail-view');
detailView.style.display = 'block';
detailView.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento dettagli...</span></div>';
currentView = 'detail';
try {
const result = await api.getPolicy(id);
if (result.success && result.data) {
renderPolicyDetail(result.data);
} else {
detailView.innerHTML = '<div class="empty-state"><h4>Policy non trovata</h4></div>';
}
} catch (e) {
detailView.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
}
}
function renderPolicyDetail(policy) {
const detailView = document.getElementById('policy-detail-view');
const catLabel = CATEGORY_LABELS[policy.category] || policy.category || '-';
const statusLabel = STATUS_LABELS[policy.status] || policy.status || 'Bozza';
const badgeClass = STATUS_BADGE_CLASS[policy.status] || 'badge-neutral';
detailView.innerHTML = `
<div class="detail-header">
<div>
<div style="margin-bottom:8px;">
<span class="text-link" onclick="backToList()" style="font-size:0.82rem;">
&larr; Torna all'elenco
</span>
</div>
<h3>${escapeHtml(policy.title)}</h3>
<div style="margin-top:8px;">
<span class="badge ${badgeClass}">${escapeHtml(statusLabel)}</span>
${policy.ai_generated ? '<span class="badge badge-info" style="margin-left:6px;">AI Generata</span>' : ''}
</div>
</div>
<div class="detail-header-actions">
${policy.status === 'draft' || policy.status === 'review' ? `
<button class="btn btn-success" onclick="approvePolicy(${policy.id})">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
Approva
</button>` : ''}
<button class="btn btn-secondary" onclick="openEditModal(${policy.id})">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
Modifica
</button>
</div>
</div>
<div class="detail-grid">
<div>
<div class="card">
<div class="card-header">
<h3>Contenuto Policy</h3>
</div>
<div class="card-body">
<div class="policy-content-box">${escapeHtml(policy.content || 'Nessun contenuto disponibile.')}</div>
</div>
</div>
</div>
<div>
<div class="meta-card">
<h4>Informazioni</h4>
<div class="meta-row">
<span class="meta-label">Categoria</span>
<span class="meta-value">${escapeHtml(catLabel)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Art. NIS2</span>
<span class="meta-value">${escapeHtml(policy.nis2_article || '-')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Versione</span>
<span class="meta-value">${escapeHtml(policy.version || '1.0')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Stato</span>
<span class="meta-value"><span class="badge ${badgeClass}">${escapeHtml(statusLabel)}</span></span>
</div>
<div class="meta-row">
<span class="meta-label">Creata il</span>
<span class="meta-value">${formatDate(policy.created_at)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Aggiornata il</span>
<span class="meta-value">${formatDate(policy.updated_at)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Prossima Revisione</span>
<span class="meta-value">${formatDate(policy.next_review_date)}</span>
</div>
${policy.approved_by_name ? `
<div class="meta-row">
<span class="meta-label">Approvata da</span>
<span class="meta-value">${escapeHtml(policy.approved_by_name)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Approvata il</span>
<span class="meta-value">${formatDate(policy.approved_at)}</span>
</div>` : ''}
</div>
</div>
</div>
`;
}
function backToList() {
document.getElementById('policy-detail-view').style.display = 'none';
document.querySelector('.tabs').style.display = '';
switchTab('list');
currentView = 'list';
}
// ── Create / Edit Modal ─────────────────────────────────
function openCreateModal() {
showPolicyFormModal(null);
}
async function openEditModal(id) {
try {
const result = await api.getPolicy(id);
if (result.success && result.data) {
showPolicyFormModal(result.data);
} else {
showNotification('Impossibile caricare la policy.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
function showPolicyFormModal(policy) {
const isEdit = !!policy;
const title = isEdit ? 'Modifica Policy' : 'Nuova Policy';
const categoryOptions = Object.entries(CATEGORY_LABELS).map(([val, label]) =>
`<option value="${val}" ${policy && policy.category === val ? 'selected' : ''}>${label}</option>`
).join('');
const content = `
<form id="policy-form" onsubmit="return false;">
<div class="form-group">
<label class="form-label">Titolo <span class="required">*</span></label>
<input type="text" class="form-input" id="form-title" value="${isEdit ? escapeHtml(policy.title) : ''}" required placeholder="Inserisci il titolo della policy">
</div>
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="form-group">
<label class="form-label">Categoria <span class="required">*</span></label>
<select class="form-select" id="form-category" required>
<option value="">Seleziona...</option>
${categoryOptions}
</select>
</div>
<div class="form-group">
<label class="form-label">Articolo NIS2</label>
<input type="text" class="form-input" id="form-nis2-article" value="${isEdit && policy.nis2_article ? escapeHtml(policy.nis2_article) : ''}" placeholder="es. 21.2.a">
</div>
</div>
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="form-group">
<label class="form-label">Versione</label>
<input type="text" class="form-input" id="form-version" value="${isEdit && policy.version ? escapeHtml(policy.version) : '1.0'}" placeholder="1.0">
</div>
<div class="form-group">
<label class="form-label">Prossima Revisione</label>
<input type="date" class="form-input" id="form-review-date" value="${isEdit && policy.next_review_date ? policy.next_review_date.substring(0, 10) : ''}">
</div>
</div>
<div class="form-group">
<label class="form-label">Contenuto</label>
<textarea class="form-textarea" id="form-content" rows="12" placeholder="Inserisci il contenuto della policy...&#10;&#10;Puoi strutturare il documento con sezioni:&#10;1. Scopo&#10;2. Ambito&#10;3. Responsabilita'&#10;4. Procedure&#10;5. Revisione">${isEdit && policy.content ? escapeHtml(policy.content) : ''}</textarea>
</div>
</form>
`;
showModal(title, content, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="savePolicy(${isEdit ? policy.id : 'null'})">${isEdit ? 'Salva Modifiche' : 'Crea Policy'}</button>
`
});
}
async function savePolicy(id) {
const title = document.getElementById('form-title').value.trim();
const category = document.getElementById('form-category').value;
if (!title) {
showNotification('Il titolo e\' obbligatorio.', 'warning');
return;
}
if (!category) {
showNotification('La categoria e\' obbligatoria.', 'warning');
return;
}
const data = {
title: title,
category: category,
nis2_article: document.getElementById('form-nis2-article').value.trim(),
version: document.getElementById('form-version').value.trim() || '1.0',
content: document.getElementById('form-content').value,
next_review_date: document.getElementById('form-review-date').value || null,
};
try {
let result;
if (id) {
result = await api.updatePolicy(id, data);
} else {
result = await api.createPolicy(data);
}
if (result.success) {
closeModal();
showNotification(id ? 'Policy aggiornata con successo.' : 'Policy creata con successo.', 'success');
loadPolicies();
if (currentView === 'detail' && id) {
viewPolicy(id);
}
} else {
showNotification(result.message || 'Errore nel salvataggio.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Approve Policy ──────────────────────────────────────
async function approvePolicy(id) {
showModal('Conferma Approvazione', `
<p>Sei sicuro di voler approvare questa policy?</p>
<p style="margin-top:8px;font-size:0.85rem;color:var(--gray-500);">Lo stato verra' modificato in "Approvata" e non sara' piu' possibile annullare l'operazione.</p>
`, {
size: 'sm',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-success" onclick="doApprovePolicy(${id})">Approva</button>
`
});
}
async function doApprovePolicy(id) {
closeModal();
try {
const result = await api.approvePolicy(id);
if (result.success) {
showNotification('Policy approvata con successo.', 'success');
loadPolicies();
if (currentView === 'detail') {
viewPolicy(id);
}
} else {
showNotification(result.message || 'Errore nell\'approvazione.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Delete Policy ───────────────────────────────────────
function confirmDeletePolicy(id, title) {
showModal('Conferma Eliminazione', `
<p>Sei sicuro di voler eliminare la policy <strong>${title}</strong>?</p>
<p style="margin-top:8px;font-size:0.85rem;color:var(--danger);">Questa azione e' irreversibile.</p>
`, {
size: 'sm',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-danger" onclick="doDeletePolicy(${id})">Elimina</button>
`
});
}
async function doDeletePolicy(id) {
closeModal();
try {
const result = await api.del(`/policies/${id}`);
if (result.success) {
showNotification('Policy eliminata.', 'success');
loadPolicies();
if (currentView === 'detail') {
backToList();
}
} else {
showNotification(result.message || 'Errore nell\'eliminazione.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── AI Generate ─────────────────────────────────────────
function openAIGenerateModal() {
const categoryOptions = Object.entries(CATEGORY_LABELS).map(([val, label]) =>
`<option value="${val}">${label}</option>`
).join('');
showModal('Genera Policy con AI', `
<p style="margin-bottom:16px;color:var(--gray-600);font-size:0.9rem;">
Seleziona la categoria per generare automaticamente una bozza di policy tramite intelligenza artificiale.
Il contenuto generato potra' essere rivisto e modificato prima della pubblicazione.
</p>
<div class="form-group">
<label class="form-label">Categoria Policy <span class="required">*</span></label>
<select class="form-select" id="ai-category">
${categoryOptions}
</select>
</div>
<div id="ai-preview-container" style="display:none;"></div>
`, {
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" id="ai-generate-btn" onclick="doAIGenerate()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><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>
Genera
</button>
`
});
}
let aiGeneratedData = null;
async function doAIGenerate() {
const category = document.getElementById('ai-category').value;
const btn = document.getElementById('ai-generate-btn');
const previewContainer = document.getElementById('ai-preview-container');
btn.disabled = true;
btn.innerHTML = '<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0 auto;"></div> Generazione...';
try {
const result = await api.aiGeneratePolicy(category);
if (result.success && result.data) {
aiGeneratedData = result.data;
previewContainer.style.display = 'block';
previewContainer.innerHTML = `
<div class="ai-preview-header">
<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>
Policy Generata: ${escapeHtml(aiGeneratedData.title || '')}
</div>
<div class="ai-preview">${escapeHtml(aiGeneratedData.content || '')}</div>
`;
// Change footer buttons
const footer = btn.closest('.modal-footer');
footer.innerHTML = `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="saveAIGeneratedPolicy()">Salva come Bozza</button>
`;
} else {
showNotification(result.message || 'Errore nella generazione AI.', 'error');
btn.disabled = false;
btn.textContent = 'Genera';
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
btn.disabled = false;
btn.textContent = 'Genera';
}
}
async function saveAIGeneratedPolicy() {
if (!aiGeneratedData) return;
const data = {
title: aiGeneratedData.title || 'Policy Generata AI',
category: aiGeneratedData.category || document.getElementById('ai-category')?.value || 'information_security',
nis2_article: aiGeneratedData.nis2_article || '',
content: aiGeneratedData.content || '',
version: '1.0',
ai_generated: 1,
};
try {
const result = await api.createPolicy(data);
if (result.success) {
closeModal();
showNotification('Policy AI salvata come bozza.', 'success');
aiGeneratedData = null;
loadPolicies();
} else {
showNotification(result.message || 'Errore nel salvataggio.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Templates ───────────────────────────────────────────
async function loadTemplates() {
const container = document.getElementById('templates-grid');
container.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento template...</span></div>';
try {
const result = await api.getPolicyTemplates();
if (result.success && result.data) {
renderTemplates(result.data);
} else {
container.innerHTML = '<div class="empty-state"><h4>Nessun template disponibile</h4></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
}
}
function renderTemplates(templates) {
const container = document.getElementById('templates-grid');
if (!templates || templates.length === 0) {
container.innerHTML = '<div class="empty-state"><h4>Nessun template disponibile</h4></div>';
return;
}
let html = '<div class="template-grid">';
templates.forEach(t => {
const catLabel = CATEGORY_LABELS[t.category] || t.category;
html += `
<div class="template-card" onclick="createFromTemplate('${escapeHtml(t.category)}', '${escapeHtml(t.title).replace(/'/g, "\\'")}', '${escapeHtml(t.nis2_article || '').replace(/'/g, "\\'")}')">
<h4>${escapeHtml(t.title)}</h4>
<div class="template-meta">
<span class="tag">${escapeHtml(catLabel)}</span>
${t.nis2_article ? `<span style="color:var(--gray-400);">Art. ${escapeHtml(t.nis2_article)}</span>` : ''}
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
function createFromTemplate(category, title, nis2Article) {
showPolicyFormModal({
title: title,
category: category,
nis2_article: nis2Article,
version: '1.0',
content: '',
});
}
// ── Init ────────────────────────────────────────────────
loadPolicies();
</script>
</body>
</html>

902
public/reports.html Normal file
View File

@ -0,0 +1,902 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audit e Report - NIS2 Agile</title>
<link rel="stylesheet" href="/nis2/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; }
/* ── Data 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 tbody tr:hover td { background: var(--gray-50); }
/* ── Status Badges ──────────────────────────────────────── */
.status-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 500;
}
.status-badge.not_started { background: var(--gray-100); color: var(--gray-600); }
.status-badge.in_progress { background: var(--info-bg); color: var(--info); }
.status-badge.implemented { background: var(--secondary-bg); color: var(--secondary); }
.status-badge.verified { background: var(--primary-bg); color: var(--primary); }
/* ── Progress Bar ───────────────────────────────────────── */
.progress-bar-container {
display: flex;
align-items: center;
gap: 8px;
}
.progress-bar {
flex-grow: 1;
height: 6px;
background: var(--gray-200);
border-radius: 3px;
overflow: hidden;
min-width: 80px;
}
.progress-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.progress-pct {
font-size: 0.8rem;
font-weight: 600;
min-width: 36px;
text-align: right;
color: var(--gray-600);
}
/* ── Report Section ─────────────────────────────────────── */
.report-container {
background: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
overflow: hidden;
}
.report-header {
background: var(--gray-800);
color: white;
padding: 32px;
text-align: center;
}
.report-header h3 {
font-size: 1.3rem;
margin-bottom: 4px;
}
.report-header p {
color: var(--gray-300);
font-size: 0.85rem;
}
.report-body {
padding: 24px 32px;
}
.report-section {
margin-bottom: 24px;
}
.report-section-title {
font-size: 1rem;
font-weight: 600;
color: var(--gray-800);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--primary-bg);
}
.report-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
}
.report-summary-item {
text-align: center;
padding: 16px;
background: var(--gray-50);
border-radius: var(--border-radius);
}
.report-summary-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--gray-800);
line-height: 1.2;
}
.report-summary-label {
font-size: 0.8rem;
color: var(--gray-500);
margin-top: 4px;
}
.report-compliance-bar {
height: 20px;
background: var(--gray-200);
border-radius: 10px;
overflow: hidden;
margin: 12px 0;
}
.report-compliance-fill {
height: 100%;
border-radius: 10px;
transition: width 0.5s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 0.75rem;
font-weight: 600;
}
/* ── ISO Mapping ────────────────────────────────────────── */
.iso-mapping-row {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--gray-100);
}
.iso-mapping-row:last-child { border-bottom: none; }
.iso-nis2-badge {
background: var(--primary-bg);
color: var(--primary);
padding: 6px 14px;
border-radius: 20px;
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
min-width: 90px;
text-align: center;
}
.iso-arrow {
color: var(--gray-300);
flex-shrink: 0;
}
.iso-arrow svg { width: 20px; height: 20px; }
.iso-controls {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex-grow: 1;
}
.iso-control-badge {
background: var(--secondary-bg);
color: var(--secondary);
padding: 4px 10px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 500;
}
.iso-title {
font-size: 0.85rem;
color: var(--gray-500);
min-width: 200px;
}
/* ── Pagination ─────────────────────────────────────────── */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin-top: 20px;
padding: 12px;
}
.pagination button {
padding: 8px 16px;
border: 1px solid var(--gray-200);
background: var(--card-bg);
border-radius: var(--border-radius-sm);
color: var(--gray-600);
cursor: pointer;
font-size: 0.85rem;
transition: all var(--transition-fast);
}
.pagination button:hover:not(:disabled) {
background: var(--primary-bg);
border-color: var(--primary);
color: var(--primary);
}
.pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pagination .page-info {
font-size: 0.85rem;
color: var(--gray-500);
}
/* ── Audit Log Action ───────────────────────────────────── */
.log-action {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.82rem;
}
.log-entity {
background: var(--gray-100);
padding: 2px 8px;
border-radius: var(--border-radius-sm);
font-size: 0.8rem;
color: var(--gray-600);
font-family: var(--font-mono);
}
.log-details {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.8rem;
color: var(--gray-400);
cursor: help;
}
/* ── Controls Row Clickable ─────────────────────────────── */
.data-table tbody tr.clickable-row { cursor: pointer; }
/* ── Loading/Empty ──────────────────────────────────────── */
.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-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;
}
/* ── Print ──────────────────────────────────────────────── */
@media print {
.sidebar, .content-header, .tab-nav, .btn { display: none !important; }
.main-content { margin: 0 !important; padding: 0 !important; }
.report-container { box-shadow: none; border: 1px solid #ddd; }
body { background: white; }
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.tab-nav button { padding: 10px 12px; font-size: 0.8rem; }
.iso-mapping-row { flex-direction: column; align-items: flex-start; }
.iso-title { min-width: auto; }
.report-summary-grid { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2>Audit e Report</h2>
<div class="content-header-actions">
<button class="btn btn-primary" onclick="generateReport()">Genera Report</button>
</div>
</header>
<div class="content-body">
<!-- Tab Navigation -->
<div class="tab-nav">
<button class="active" onclick="switchTab('report', this)">Report Compliance</button>
<button onclick="switchTab('controls', this)">Controlli</button>
<button onclick="switchTab('audit', this)">Audit Log</button>
<button onclick="switchTab('iso', this)">ISO 27001</button>
</div>
<!-- Tab: Report Compliance -->
<div class="tab-panel active" id="tab-report">
<div id="report-container">
<div class="empty-state-box">
<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>
<h4>Nessun report generato</h4>
<p>Clicca "Genera Report" per creare un report di compliance aggiornato.</p>
</div>
</div>
</div>
<!-- Tab: Controlli -->
<div class="tab-panel" id="tab-controls">
<div id="controls-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento controlli...</p>
</div>
</div>
</div>
<!-- Tab: Audit Log -->
<div class="tab-panel" id="tab-audit">
<div id="audit-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento log...</p>
</div>
</div>
</div>
<!-- Tab: ISO 27001 -->
<div class="tab-panel" id="tab-iso">
<div id="iso-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento mapping...</p>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/nis2/js/api.js"></script>
<script src="/nis2/js/common.js"></script>
<script>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
// ── Labels ──────────────────────────────────────────────
const controlStatusLabels = {
not_started: 'Non Iniziato',
in_progress: 'In Corso',
implemented: 'Implementato',
verified: 'Verificato'
};
const actionLabels = {
login: 'Accesso effettuato',
logout: 'Disconnessione',
user_created: 'Utente creato',
org_created: 'Organizzazione creata',
assessment_created: 'Assessment creato',
assessment_completed: 'Assessment completato',
risk_created: 'Rischio creato',
risk_updated: 'Rischio aggiornato',
risk_deleted: 'Rischio eliminato',
incident_created: 'Incidente registrato',
incident_updated: 'Incidente aggiornato',
early_warning_sent: 'Pre-allarme inviato',
notification_sent: 'Notifica inviata',
policy_created: 'Policy creata',
policy_updated: 'Policy aggiornata',
policy_approved: 'Policy approvata',
policy_ai_generated: 'Policy generata con AI',
supplier_created: 'Fornitore creato',
supplier_assessed: 'Fornitore valutato',
course_created: 'Corso creato',
training_assigned: 'Formazione assegnata',
asset_created: 'Asset registrato',
asset_updated: 'Asset aggiornato',
asset_deleted: 'Asset eliminato',
control_updated: 'Controllo aggiornato',
evidence_uploaded: 'Evidenza caricata'
};
// ── State ───────────────────────────────────────────────
let auditPage = 1;
let auditTotalPages = 1;
// ── 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 === 'controls') loadControls();
if (tabId === 'audit') { auditPage = 1; loadAuditLogs(); }
if (tabId === 'iso') loadIsoMapping();
}
// ══════════════════════════════════════════════════════════
// TAB 1: Compliance Report
// ══════════════════════════════════════════════════════════
async function generateReport() {
const container = document.getElementById('report-container');
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Generazione report in corso...</p></div>';
// Switch to report tab
document.querySelectorAll('.tab-nav button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.querySelector('.tab-nav button').classList.add('active');
document.getElementById('tab-report').classList.add('active');
try {
const result = await api.generateComplianceReport();
if (result.success && result.data) {
renderReport(result.data);
} else {
container.innerHTML = '<div class="empty-state-box"><h4>Errore nella generazione</h4><p>' + escapeHtml(result.message || '') + '</p></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4></div>';
}
}
function renderReport(data) {
const container = document.getElementById('report-container');
const org = data.organization || {};
const summary = data.compliance_summary || {};
const controls = data.controls || [];
const pct = summary.compliance_percentage || 0;
const pctColor = pct >= 80 ? 'var(--secondary)' : pct >= 50 ? 'var(--warning)' : 'var(--danger)';
let controlsTableHtml = '';
controls.forEach(c => {
const st = c.status || 'not_started';
const implPct = c.implementation_percentage || 0;
const implColor = implPct >= 80 ? 'var(--secondary)' : implPct >= 50 ? 'var(--warning)' : 'var(--danger)';
controlsTableHtml += `
<tr>
<td style="font-family:var(--font-mono); font-size:0.8rem;">${escapeHtml(c.control_code || '-')}</td>
<td>${escapeHtml(c.title || '-')}</td>
<td><span class="status-badge ${st}">${controlStatusLabels[st] || st}</span></td>
<td>
<div class="progress-bar-container">
<div class="progress-bar"><div class="progress-bar-fill" style="width:${implPct}%; background:${implColor};"></div></div>
<span class="progress-pct">${implPct}%</span>
</div>
</td>
</tr>`;
});
container.innerHTML = `
<div class="report-container">
<div class="report-header">
<h3>Report di Compliance NIS2</h3>
<p>${escapeHtml(org.name || 'Organizzazione')} - ${formatDateTime(data.report_date)}</p>
</div>
<div class="report-body">
<!-- Organization Info -->
<div class="report-section">
<div class="report-section-title">Informazioni Organizzazione</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; font-size:0.9rem;">
<div><strong>Nome:</strong> ${escapeHtml(org.name || '-')}</div>
<div><strong>Settore:</strong> ${escapeHtml(org.sector || '-')}</div>
<div><strong>Tipo Entita':</strong> ${escapeHtml(org.entity_type || '-')}</div>
<div><strong>Paese:</strong> ${escapeHtml(org.country || '-')}</div>
</div>
</div>
<!-- Compliance Summary -->
<div class="report-section">
<div class="report-section-title">Riepilogo Compliance</div>
<div class="report-compliance-bar">
<div class="report-compliance-fill" style="width:${pct}%; background:${pctColor};">${pct}%</div>
</div>
<div class="report-summary-grid">
<div class="report-summary-item">
<div class="report-summary-value">${summary.total_controls || 0}</div>
<div class="report-summary-label">Controlli Totali</div>
</div>
<div class="report-summary-item">
<div class="report-summary-value">${summary.implemented_controls || 0}</div>
<div class="report-summary-label">Implementati</div>
</div>
<div class="report-summary-item">
<div class="report-summary-value" style="color:var(--danger);">${data.risk_count || 0}</div>
<div class="report-summary-label">Rischi Aperti</div>
</div>
<div class="report-summary-item">
<div class="report-summary-value" style="color:var(--warning);">${data.incident_count || 0}</div>
<div class="report-summary-label">Incidenti</div>
</div>
<div class="report-summary-item">
<div class="report-summary-value" style="color:var(--secondary);">${data.policy_count || 0}</div>
<div class="report-summary-label">Policy Approvate</div>
</div>
</div>
</div>
<!-- Controls Status -->
${controls.length > 0 ? `
<div class="report-section">
<div class="report-section-title">Stato Controlli</div>
<table class="data-table" style="box-shadow:none; border:1px solid var(--gray-200);">
<thead>
<tr>
<th>Codice</th>
<th>Titolo</th>
<th>Stato</th>
<th>Implementazione</th>
</tr>
</thead>
<tbody>${controlsTableHtml}</tbody>
</table>
</div>
` : ''}
<!-- Print Button -->
<div style="text-align:center; margin-top:24px;">
<button class="btn btn-secondary" onclick="window.print()" style="gap:6px;">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M5 4v3H4a2 2 0 00-2 2v3a2 2 0 002 2h1v2a2 2 0 002 2h6a2 2 0 002-2v-2h1a2 2 0 002-2V9a2 2 0 00-2-2h-1V4a2 2 0 00-2-2H7a2 2 0 00-2 2zm8 0H7v3h6V4zm0 8H7v4h6v-4z" clip-rule="evenodd"/></svg>
Stampa Report
</button>
</div>
</div>
</div>
`;
}
// ══════════════════════════════════════════════════════════
// TAB 2: Controls
// ══════════════════════════════════════════════════════════
async function loadControls() {
const container = document.getElementById('controls-container');
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento controlli...</p></div>';
try {
const result = await api.listControls();
if (result.success && result.data) {
renderControls(result.data);
} 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 renderControls(controls) {
const container = document.getElementById('controls-container');
if (!controls || controls.length === 0) {
container.innerHTML = `
<div class="empty-state-box">
<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>
<h4>Nessun controllo configurato</h4>
<p>I controlli di compliance appariranno qui dopo il primo assessment.</p>
</div>`;
return;
}
let html = `
<table class="data-table">
<thead>
<tr>
<th>Codice</th>
<th>Framework</th>
<th>Titolo</th>
<th>Stato</th>
<th>% Implementazione</th>
<th>Responsabile</th>
<th>Prossima Verifica</th>
</tr>
</thead>
<tbody>`;
controls.forEach(c => {
const st = c.status || 'not_started';
const implPct = c.implementation_percentage || 0;
const implColor = implPct >= 80 ? 'var(--secondary)' : implPct >= 50 ? 'var(--warning)' : 'var(--danger)';
html += `
<tr class="clickable-row" onclick="showEditControlModal(${c.id}, this)" title="Clicca per modificare">
<td style="font-family:var(--font-mono); font-size:0.82rem; font-weight:600;">${escapeHtml(c.control_code || '-')}</td>
<td>${escapeHtml(c.framework || 'NIS2')}</td>
<td style="font-weight:500;">${escapeHtml(c.title || '-')}</td>
<td><span class="status-badge ${st}">${controlStatusLabels[st] || st}</span></td>
<td>
<div class="progress-bar-container">
<div class="progress-bar"><div class="progress-bar-fill" style="width:${implPct}%; background:${implColor};"></div></div>
<span class="progress-pct">${implPct}%</span>
</div>
</td>
<td>${escapeHtml(c.responsible_name || '-')}</td>
<td>${formatDate(c.next_review_date)}</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
function showEditControlModal(id) {
// First, find the control from the table data
api.listControls().then(result => {
if (!result.success || !result.data) return;
const control = result.data.find(c => c.id === id);
if (!control) return;
const st = control.status || 'not_started';
const implPct = control.implementation_percentage || 0;
const statusOptions = Object.entries(controlStatusLabels).map(([val, label]) =>
`<option value="${val}" ${st === val ? 'selected' : ''}>${label}</option>`
).join('');
showModal('Modifica Controllo: ' + escapeHtml(control.control_code || ''), `
<div class="form-group">
<label class="form-label">Titolo</label>
<p style="font-size:0.9rem; color:var(--gray-700); padding:8px 0;">${escapeHtml(control.title || '-')}</p>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<div class="form-group">
<label class="form-label">Stato</label>
<select class="form-select" id="ctrl-status">${statusOptions}</select>
</div>
<div class="form-group">
<label class="form-label">% Implementazione</label>
<input type="range" id="ctrl-impl-pct" min="0" max="100" value="${implPct}" oninput="document.getElementById('ctrl-impl-pct-val').textContent = this.value + '%'" style="width:100%; margin-top:8px;">
<span id="ctrl-impl-pct-val" style="font-size:0.85rem; font-weight:600; color:var(--gray-700);">${implPct}%</span>
</div>
</div>
<div class="form-group">
<label class="form-label">Descrizione Evidenza</label>
<textarea class="form-input" id="ctrl-evidence" rows="3" placeholder="Descrivi le evidenze di implementazione...">${escapeHtml(control.evidence_description || '')}</textarea>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<div class="form-group">
<label class="form-label">Responsabile (ID utente)</label>
<input type="text" class="form-input" id="ctrl-responsible" value="${escapeHtml(control.responsible_user_id || '')}">
</div>
<div class="form-group">
<label class="form-label">Prossima Verifica</label>
<input type="date" class="form-input" id="ctrl-next-review" value="${control.next_review_date || ''}">
</div>
</div>
`, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="saveControl(${id})">Salva</button>
`
});
});
}
async function saveControl(id) {
const data = {
status: document.getElementById('ctrl-status').value,
implementation_percentage: parseInt(document.getElementById('ctrl-impl-pct').value),
evidence_description: document.getElementById('ctrl-evidence').value.trim() || null,
responsible_user_id: document.getElementById('ctrl-responsible').value.trim() || null,
next_review_date: document.getElementById('ctrl-next-review').value || null,
};
closeModal();
try {
const result = await api.updateControl(id, data);
if (result.success) {
showNotification('Controllo aggiornato con successo!', 'success');
loadControls();
} else {
showNotification(result.message || 'Errore nell\'aggiornamento.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ══════════════════════════════════════════════════════════
// TAB 3: Audit Logs
// ══════════════════════════════════════════════════════════
async function loadAuditLogs() {
const container = document.getElementById('audit-container');
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento log...</p></div>';
try {
const result = await api.getAuditLogs({ page: auditPage, per_page: 25 });
if (result.success) {
const logs = result.data || [];
const pagination = result.pagination || {};
auditTotalPages = pagination.total_pages || 1;
renderAuditLogs(logs, pagination);
} 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></div>';
}
}
function renderAuditLogs(logs, pagination) {
const container = document.getElementById('audit-container');
if (!logs || logs.length === 0) {
container.innerHTML = `
<div class="empty-state-box">
<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>
<h4>Nessun log disponibile</h4>
<p>Le attivita' di audit appariranno qui.</p>
</div>`;
return;
}
let html = `
<table class="data-table">
<thead>
<tr>
<th>Data/Ora</th>
<th>Utente</th>
<th>Azione</th>
<th>Entita'</th>
<th>Dettagli</th>
</tr>
</thead>
<tbody>`;
logs.forEach(log => {
const actionText = actionLabels[log.action] || log.action || '-';
let detailsText = '-';
if (log.details) {
try {
const d = typeof log.details === 'string' ? JSON.parse(log.details) : log.details;
detailsText = Object.entries(d).map(([k, v]) => `${k}: ${v}`).join(', ');
} catch (e) {
detailsText = String(log.details);
}
}
html += `
<tr>
<td style="white-space:nowrap;">${formatDateTime(log.created_at)}</td>
<td>${escapeHtml(log.full_name || '-')}</td>
<td><span class="log-action">${escapeHtml(actionText)}</span></td>
<td>
${log.entity_type ? `<span class="log-entity">${escapeHtml(log.entity_type)}</span>` : '-'}
${log.entity_id ? `<span style="font-size:0.8rem; color:var(--gray-400);"> #${log.entity_id}</span>` : ''}
</td>
<td><span class="log-details" title="${escapeHtml(detailsText)}">${escapeHtml(detailsText)}</span></td>
</tr>`;
});
html += '</tbody></table>';
// Pagination
const totalPages = pagination.total_pages || 1;
const currentPage = pagination.page || auditPage;
html += `
<div class="pagination">
<button onclick="goAuditPage(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>Precedente</button>
<span class="page-info">Pagina ${currentPage} di ${totalPages}</span>
<button onclick="goAuditPage(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>Successiva</button>
</div>`;
container.innerHTML = html;
}
function goAuditPage(page) {
if (page < 1 || page > auditTotalPages) return;
auditPage = page;
loadAuditLogs();
}
// ══════════════════════════════════════════════════════════
// TAB 4: ISO 27001 Mapping
// ══════════════════════════════════════════════════════════
async function loadIsoMapping() {
const container = document.getElementById('iso-container');
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Caricamento mapping ISO 27001...</p></div>';
try {
const result = await api.getIsoMapping();
if (result.success && result.data) {
renderIsoMapping(result.data);
} 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 renderIsoMapping(mapping) {
const container = document.getElementById('iso-container');
if (!mapping || mapping.length === 0) {
container.innerHTML = '<div class="empty-state-box"><h4>Nessun mapping disponibile</h4></div>';
return;
}
let html = `
<div class="card">
<div class="card-header">
<h3>Mapping NIS2 - ISO 27001:2022</h3>
</div>
<div class="card-body">
<p style="font-size:0.85rem; color:var(--gray-500); margin-bottom:20px;">
Corrispondenza tra gli articoli della Direttiva NIS2 e i controlli dello standard ISO/IEC 27001:2022.
</p>`;
mapping.forEach(m => {
const isoControls = (m.iso27001 || '').split(',').map(c => c.trim()).filter(c => c);
const controlBadges = isoControls.map(c => `<span class="iso-control-badge">${escapeHtml(c)}</span>`).join('');
html += `
<div class="iso-mapping-row">
<span class="iso-nis2-badge">Art. ${escapeHtml(m.nis2)}</span>
<span class="iso-arrow">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</span>
<div class="iso-controls">${controlBadges}</div>
<span class="iso-title">${escapeHtml(m.title || '')}</span>
</div>`;
});
html += '</div></div>';
container.innerHTML = html;
}
// ── Initial: no auto-load, user triggers report ─────────
</script>
</body>
</html>

1120
public/risks.html Normal file

File diff suppressed because it is too large Load Diff

1006
public/settings.html Normal file

File diff suppressed because it is too large Load Diff

1133
public/supply-chain.html Normal file

File diff suppressed because it is too large Load Diff

725
public/training.html Normal file
View File

@ -0,0 +1,725 @@
<!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="/nis2/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>Formazione</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="/nis2/js/api.js"></script>
<script src="/nis2/js/common.js"></script>
<script>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
// ── 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>