[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:
parent
c03d22ea48
commit
73e78ea6b4
375
public/admin/index.html
Normal file
375
public/admin/index.html
Normal 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>
|
||||
297
public/admin/organizations.html
Normal file
297
public/admin/organizations.html
Normal 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' : ''}>
|
||||
« Precedente
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="loadOrganizations(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
|
||||
Successivo »
|
||||
</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
336
public/admin/users.html
Normal 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' : ''}>
|
||||
« Precedente
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="loadUsers(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
|
||||
Successivo »
|
||||
</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
764
public/assets.html
Normal 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
1148
public/incidents.html
Normal file
File diff suppressed because it is too large
Load Diff
930
public/policies.html
Normal file
930
public/policies.html
Normal 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;">
|
||||
← 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... Puoi strutturare il documento con sezioni: 1. Scopo 2. Ambito 3. Responsabilita' 4. Procedure 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
902
public/reports.html
Normal 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
1120
public/risks.html
Normal file
File diff suppressed because it is too large
Load Diff
1006
public/settings.html
Normal file
1006
public/settings.html
Normal file
File diff suppressed because it is too large
Load Diff
1133
public/supply-chain.html
Normal file
1133
public/supply-chain.html
Normal file
File diff suppressed because it is too large
Load Diff
725
public/training.html
Normal file
725
public/training.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user