Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1252 lines
68 KiB
HTML
1252 lines
68 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="it">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>Inventario · NIS2 Agile</title>
|
||
<!-- UI V3: Inter self-host + design system v3.css (self-host only, nessun CDN esterno). -->
|
||
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
|
||
<link rel="stylesheet" href="/css/v3.css?v=20260627">
|
||
<!-- style.css resta caricato DOPO v3.css: veste i widget renderizzati via JS
|
||
(tabelle/modali/form/badge). È self-hostato (nessun CDN). v3.css governa chrome + shell. -->
|
||
<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); }
|
||
/* ── Intestazioni ordinabili (click per ordinare la lista) ── */
|
||
.data-table th.sortable-th { cursor: pointer; user-select: none; white-space: nowrap; transition: background var(--transition-fast), color var(--transition-fast); }
|
||
.data-table th.sortable-th:hover { background: var(--gray-100); color: var(--gray-700); }
|
||
.data-table th.sortable-th:focus-visible { outline: 2px solid var(--primary, #0066CC); outline-offset: -2px; }
|
||
.data-table th.sortable-th.sorted { color: var(--gray-700); }
|
||
.data-table th.sortable-th .sort-ind { font-size: 0.7rem; opacity: 0.35; margin-left: 3px; }
|
||
.data-table th.sortable-th .sort-ind.active { opacity: 1; color: var(--primary, #0066CC); }
|
||
|
||
/* ── 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>
|
||
<!-- PWA:start -->
|
||
<link rel="manifest" href="/manifest.webmanifest">
|
||
<meta name="theme-color" content="#0066CC">
|
||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
|
||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
|
||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
|
||
<meta name="application-name" content="NIS2 Agile">
|
||
<script src="/js/pwa.js?v=20260614" defer></script>
|
||
<!-- PWA:end -->
|
||
</head>
|
||
<body>
|
||
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
|
||
<div id="topnav"></div>
|
||
|
||
<main class="wrap">
|
||
<nav class="crumb" aria-label="breadcrumb">
|
||
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
|
||
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="assets.title">Inventario</span>
|
||
</nav>
|
||
<div class="page-head">
|
||
<div>
|
||
<h1 data-i18n="assets.title">Inventario</h1>
|
||
<p class="sub">Beni, perimetro tecnico e rilevanza NIS2 · Art. 21.2.i NIS2</p>
|
||
</div>
|
||
<div class="content-header-actions" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||
<button class="btn-ghost" onclick="showDependencyMap()" title="Mappa delle dipendenze tra i beni"><i data-lucide="git-fork" class="ic"></i>Mappa Dipendenze</button>
|
||
<button class="btn-ghost" onclick="openImportModal()" title="Importa beni da CSV/CMDB"><i data-lucide="upload" class="ic"></i>Importa</button>
|
||
<button class="btn-ghost" onclick="exportAssets()" title="Esporta l'inventario in CSV (stesso formato dell'importazione)"><i data-lucide="download" class="ic"></i>Esporta</button>
|
||
<button class="btn-primary" onclick="showCreateAssetModal()"><i data-lucide="plus" class="ic"></i>Nuovo bene</button>
|
||
</div>
|
||
</div>
|
||
|
||
<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 beni</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">Beni 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()" aria-label="Filtra per tipo">
|
||
<option value="">Tutte le voci</option>
|
||
<option value="ID.AM-01">Hardware (ID.AM-01)</option>
|
||
<option value="ID.AM-02">Software/Servizi/Sistemi (ID.AM-02)</option>
|
||
</select>
|
||
<select id="filter-criticality" onchange="applyFilters()" aria-label="Filtra per criticità">
|
||
<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()" aria-label="Filtra per stato">
|
||
<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 inventario...</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
|
||
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → pagina -->
|
||
<script src="/js/api.js?v=20260730a"></script>
|
||
<script src="/js/common.js?v=20260729b"></script>
|
||
<script src="/vendor/lucide/lucide.min.js"></script>
|
||
<script src="/js/topnav-v3.js?v=20260729"></script>
|
||
<script src="/js/i18n.js?v=20260731"></script>
|
||
<script src="/js/help.js?v=20260806b"></script>
|
||
<script>
|
||
// ── Auth & Init ─────────────────────────────────────────
|
||
if (!checkAuth()) throw new Error('Not authenticated');
|
||
loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
|
||
I18n.init();
|
||
HelpSystem.init();
|
||
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
|
||
loadSubclasses(); // C4 — voci+sottoclassi per i selettori (fire-and-forget, pronto al primo "Nuovo")
|
||
|
||
// ── 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'
|
||
};
|
||
|
||
// ── Voci / sottoclassi (Epic C / C4) ────────────────────
|
||
const VOCE_LABELS = { 'ID.AM-01': 'Hardware', 'ID.AM-02': 'Software/Servizi/Sistemi' };
|
||
let SUBCLASSES = []; // [{voce_code,label,subclassi:[{id,label,is_default}]}]
|
||
function assetTipo(a) {
|
||
const v = a.voce_label || VOCE_LABELS[a.voce_code] || (typeLabels[a.asset_type] || a.asset_type || '-');
|
||
return a.subclass_label ? (v + ' · ' + a.subclass_label) : v;
|
||
}
|
||
async function loadSubclasses() {
|
||
try { const r = await api.listAssetSubclasses(); if (r && r.success) SUBCLASSES = (r.data && r.data.voci) || []; }
|
||
catch (e) { SUBCLASSES = []; }
|
||
}
|
||
function voceOptionsHtml(sel) {
|
||
return ['ID.AM-01', 'ID.AM-02'].map(v =>
|
||
`<option value="${v}" ${sel === v ? 'selected' : ''}>${v} — ${VOCE_LABELS[v]}</option>`).join('');
|
||
}
|
||
function subclassOptionsHtml(voce, sel) {
|
||
const grp = SUBCLASSES.find(g => g.voce_code === voce);
|
||
const subs = grp ? grp.subclassi : [];
|
||
return '<option value="">— nessuna —</option>' + subs.map(s =>
|
||
`<option value="${s.id}" ${String(sel) === String(s.id) ? 'selected' : ''}>${escapeHtml(s.label)}</option>`).join('');
|
||
}
|
||
function onVoceChange() {
|
||
const voce = document.getElementById('asset-voce').value;
|
||
document.getElementById('asset-subclass').innerHTML = subclassOptionsHtml(voce, '');
|
||
}
|
||
async function addSubclassPrompt() {
|
||
const voce = document.getElementById('asset-voce').value;
|
||
const label = (prompt('Nuova sottoclasse per «' + VOCE_LABELS[voce] + '»:') || '').trim();
|
||
if (!label) return;
|
||
try {
|
||
const r = await api.addAssetSubclass({ voce_code: voce, label: label });
|
||
if (r && r.success) { await loadSubclasses(); document.getElementById('asset-subclass').innerHTML = subclassOptionsHtml(voce, r.data.id); showNotification('Sottoclasse aggiunta.', 'success'); }
|
||
else showNotification((r && r.message) || 'Errore.', 'warning');
|
||
} catch (e) { showNotification('Errore aggiunta sottoclasse.', 'warning'); }
|
||
}
|
||
async function renameSubclassPrompt() {
|
||
const sel = document.getElementById('asset-subclass');
|
||
const id = sel.value;
|
||
if (!id) { showNotification('Seleziona prima una sottoclasse da rinominare.', 'info'); return; }
|
||
const cur = sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : '';
|
||
const label = (prompt('Nuovo nome per la sottoclasse:', cur) || '').trim();
|
||
if (!label || label === cur) return;
|
||
const voce = document.getElementById('asset-voce').value;
|
||
try {
|
||
const r = await api.updateAssetSubclass(id, { label: label });
|
||
if (r && r.success) { await loadSubclasses(); document.getElementById('asset-subclass').innerHTML = subclassOptionsHtml(voce, id); showNotification('Sottoclasse rinominata.', 'success'); }
|
||
else showNotification((r && r.message) || 'Operazione non consentita (solo sottoclassi della tua organizzazione).', 'warning');
|
||
} catch (e) { showNotification('Errore rinomina sottoclasse.', 'warning'); }
|
||
}
|
||
async function removeSubclassPrompt() {
|
||
const sel = document.getElementById('asset-subclass');
|
||
const id = sel.value;
|
||
if (!id) { showNotification('Seleziona prima una sottoclasse da rimuovere.', 'info'); return; }
|
||
const cur = sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : '';
|
||
if (!confirm('Rimuovere la sottoclasse «' + cur + '»? Gli asset che la usano resteranno senza sottoclasse.')) return;
|
||
const voce = document.getElementById('asset-voce').value;
|
||
try {
|
||
const r = await api.deleteAssetSubclass(id);
|
||
if (r && r.success) { await loadSubclasses(); document.getElementById('asset-subclass').innerHTML = subclassOptionsHtml(voce, ''); showNotification('Sottoclasse rimossa.', 'success'); }
|
||
else showNotification((r && r.message) || 'Operazione non consentita (solo sottoclassi della tua organizzazione).', 'warning');
|
||
} catch (e) { showNotification('Errore rimozione sottoclasse.', 'warning'); }
|
||
}
|
||
|
||
// ── 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 inventario...</p></div>';
|
||
|
||
const params = { per_page: 100 };
|
||
const type = document.getElementById('filter-type').value;
|
||
const criticality = document.getElementById('filter-criticality').value;
|
||
const status = document.getElementById('filter-status').value;
|
||
if (criticality) params.criticality = criticality;
|
||
if (status) params.status = status;
|
||
|
||
try {
|
||
const result = await api.listAssets(params);
|
||
if (result.success) {
|
||
// /assets/list risponde paginato: data = {items, total, ...}. Estrai l'array.
|
||
allAssets = (result.data && result.data.items) || (Array.isArray(result.data) ? result.data : []);
|
||
const view = type ? allAssets.filter(a => a.voce_code === type) : allAssets;
|
||
updateStats(view);
|
||
renderAssets(view);
|
||
} 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 l\'inventario.</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.voce_label || VOCE_LABELS[a.voce_code] || 'Non classificato';
|
||
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> ${escapeHtml(type)}</span>`
|
||
).join('');
|
||
}
|
||
|
||
// ── Ordinamento colonne (client-side, sull'inventario gia' caricato) ──
|
||
// L'inventario e' caricato per intero lato client (nessuna paginazione),
|
||
// quindi l'ordinamento sull'array e' completo e non parziale.
|
||
let lastView = [];
|
||
let sortState = { key: null, dir: 1 }; // dir: 1 = crescente, -1 = decrescente
|
||
const ASSET_COLUMNS = [
|
||
{ key: 'name', label: 'Nome', type: 'text', val: a => (a.name || '') },
|
||
{ key: 'type', label: 'Tipo', type: 'text', val: a => assetTipo(a) },
|
||
{ key: 'category', label: 'Categoria', type: 'text', val: a => (a.category || '') },
|
||
{ key: 'criticality', label: "Criticita'", type: 'num', val: a => ({ low: 1, medium: 2, high: 3, critical: 4 }[a.criticality || 'medium'] || 0) },
|
||
{ key: 'relevance', label: 'Rilevanza NIS2', type: 'num', val: a => (a.relevance_score == null ? -1 : Number(a.relevance_score)) },
|
||
{ key: 'owner', label: 'Owner', type: 'text', val: a => (a.owner_name || '') },
|
||
{ key: 'status', label: 'Stato', type: 'text', val: a => (a.status || '') }
|
||
];
|
||
|
||
function sortableTh(c) {
|
||
const active = sortState.key === c.key;
|
||
const ariaSort = active ? (sortState.dir === 1 ? 'ascending' : 'descending') : 'none';
|
||
const ind = active ? (sortState.dir === 1 ? '▲' : '▼') : '⇅';
|
||
const indClass = active ? 'sort-ind active' : 'sort-ind';
|
||
return `<th class="sortable-th${active ? ' sorted' : ''}" role="button" tabindex="0" aria-sort="${ariaSort}" `
|
||
+ `title="Ordina per ${escapeHtml(c.label)}" onclick="sortBy('${c.key}')" `
|
||
+ `onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();sortBy('${c.key}');}">`
|
||
+ `${escapeHtml(c.label)} <span class="${indClass}" aria-hidden="true">${ind}</span></th>`;
|
||
}
|
||
|
||
function sortAssetsView(assets) {
|
||
if (!sortState.key) return assets;
|
||
const col = ASSET_COLUMNS.find(c => c.key === sortState.key);
|
||
if (!col) return assets;
|
||
const copy = assets.slice();
|
||
copy.sort((a, b) => {
|
||
const va = col.val(a), vb = col.val(b);
|
||
let cmp;
|
||
if (col.type === 'num') cmp = (Number(va) - Number(vb));
|
||
else cmp = String(va).localeCompare(String(vb), 'it', { numeric: true, sensitivity: 'base' });
|
||
if (cmp === 0) return 0;
|
||
return cmp * sortState.dir;
|
||
});
|
||
return copy;
|
||
}
|
||
|
||
function sortBy(key) {
|
||
if (sortState.key === key) sortState.dir *= -1;
|
||
else { sortState.key = key; sortState.dir = 1; }
|
||
renderAssets(lastView);
|
||
}
|
||
|
||
function renderAssets(assets) {
|
||
const container = document.getElementById('assets-container');
|
||
lastView = assets || [];
|
||
|
||
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 bene trovato</h4>
|
||
<p>Registra il primo bene per costruire l'inventario.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
const sortedAssets = sortAssetsView(assets);
|
||
|
||
let html = `
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>${ASSET_COLUMNS.map(sortableTh).join('')}<th>Azioni</th></tr>
|
||
</thead>
|
||
<tbody>`;
|
||
|
||
sortedAssets.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>${escapeHtml(assetTipo(asset))}</td>
|
||
<td>${escapeHtml(asset.category || '-')}</td>
|
||
<td><span class="criticality-badge ${crit}">${criticalityLabels[crit] || crit}</span></td>
|
||
<td>${relevanceBadge(asset)}</td>
|
||
<td>${escapeHtml(asset.owner_name || '-')}</td>
|
||
<td><span class="status-badge ${st}">${statusLabels[st] || st}</span></td>
|
||
<td style="white-space:nowrap;">
|
||
<button class="btn-icon" onclick="event.stopPropagation(); showScoringModal(${asset.id})" title="Valuta rilevanza NIS2">
|
||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 3a1 1 0 011 1v12h12a1 1 0 110 2H3a1 1 0 01-1-1V4a1 1 0 011-1zm14.707 4.707a1 1 0 00-1.414-1.414L12 10.586 9.707 8.293a1 1 0 00-1.414 0L4.586 12 6 13.414l2.293-2.293L10.586 13l5.121-5.293z" clip-rule="evenodd"/></svg>
|
||
</button>
|
||
<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>
|
||
<button class="btn-icon" onclick="event.stopPropagation(); confirmDeleteAsset(${asset.id})" title="Elimina bene">
|
||
<svg viewBox="0 0 20 20" fill="currentColor"><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>
|
||
</td>
|
||
</tr>`;
|
||
});
|
||
|
||
html += '</tbody></table>';
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// ── Rilevanza NIS2 (GV.OC-04) ───────────────────────────
|
||
const relevanceClassColors = {
|
||
critico: '#dc2626', alto: '#ea580c', medio: '#ca8a04',
|
||
basso: '#2563eb', trascurabile: '#6b7280'
|
||
};
|
||
|
||
function relevanceBadge(asset) {
|
||
if (asset.relevance_score === null || asset.relevance_score === undefined || asset.relevance_score === '') {
|
||
return '<span style="color:var(--gray-400); font-size:0.8rem;">Da valutare</span>';
|
||
}
|
||
const cls = asset.relevance_class || 'trascurabile';
|
||
const color = relevanceClassColors[cls] || '#6b7280';
|
||
const rel = Number(asset.is_nis2_relevant) ? ' ✓' : '';
|
||
return `<span style="display:inline-flex;align-items:center;gap:6px;font-size:0.8rem;font-weight:600;color:${color};">
|
||
<span style="display:inline-block;min-width:30px;text-align:center;padding:2px 6px;border-radius:6px;background:${color}1a;">${asset.relevance_score}</span>
|
||
${cls.charAt(0).toUpperCase() + cls.slice(1)}${rel}</span>`;
|
||
}
|
||
|
||
let _scoringGrid = null;
|
||
async function loadScoringGrid() {
|
||
if (_scoringGrid) return _scoringGrid;
|
||
const r = await api.getScoringGrid();
|
||
if (r.success) _scoringGrid = r.data;
|
||
return _scoringGrid;
|
||
}
|
||
|
||
async function showScoringModal(id) {
|
||
try {
|
||
const [assetRes, grid] = await Promise.all([api.getAsset(id), loadScoringGrid()]);
|
||
if (!assetRes.success || !grid) { showNotification('Errore caricamento dati.', 'error'); return; }
|
||
const a = assetRes.data;
|
||
let prev = a.relevance_criteria;
|
||
if (typeof prev === 'string') { try { prev = JSON.parse(prev); } catch (e) { prev = null; } }
|
||
|
||
let body = `<p style="font-size:0.85rem;color:var(--gray-600);margin-bottom:1rem;">
|
||
Metodologia di scoring rilevanza NIS2 (requisito <strong>GV.OC-04</strong>). Soglia rilevanza: <strong>≥${grid.threshold} punti</strong>.
|
||
Il punteggio aggiorna automaticamente anche la criticita del bene.</p>`;
|
||
|
||
for (const [key, def] of Object.entries(grid.grid)) {
|
||
// relevance_criteria e' salvato come mappa piatta {key: "optionValue"} (vedi AssetController::score).
|
||
// Gestiamo anche la forma legacy a oggetto {value:...} per retro-compatibilita'.
|
||
const pv = prev ? prev[key] : null;
|
||
const sel = (pv != null) ? (typeof pv === 'object' ? (pv.value || '') : pv) : '';
|
||
let opts = `<option value="">— seleziona —</option>`;
|
||
for (const [ov, od] of Object.entries(def.options)) {
|
||
opts += `<option value="${ov}" data-pts="${od.points}" data-lbl="${escapeHtml(od.label)}" data-desc="${escapeHtml(od.desc || '')}" ${ov === sel ? 'selected' : ''}>${od.label} (${od.points})</option>`;
|
||
}
|
||
body += `<div class="form-group" style="margin-bottom:0.75rem;">
|
||
<label class="form-label" style="font-weight:600;">${def.label} <span style="color:var(--gray-400);font-weight:400;">(max ${def.max})</span></label>
|
||
<div style="font-size:0.78rem;color:var(--gray-500);margin-bottom:4px;">${def.help}</div>
|
||
<select class="form-select score-criterion" data-key="${key}" data-crit-label="${escapeHtml(def.label)}" data-max="${def.max}" onchange="updateScorePreview()" aria-label="${def.label}">${opts}</select>
|
||
<div class="score-meaning" id="meaning-${key}" style="font-size:0.78rem;color:var(--gray-600);margin-top:4px;min-height:1.1em;font-style:italic;"></div>
|
||
</div>`;
|
||
}
|
||
body += `<div id="score-preview" style="margin-top:1rem;padding:0.9rem;border-radius:10px;background:var(--gray-50);font-weight:600;text-align:center;">
|
||
Totale: <span id="score-total">0</span>/100 — <span id="score-class">—</span></div>
|
||
<div id="score-breakdown" style="margin-top:0.6rem;font-size:0.8rem;color:var(--gray-700);"></div>`;
|
||
|
||
showModal(`Valuta Rilevanza NIS2 — ${escapeHtml(a.name)}`, body, {
|
||
size: 'lg',
|
||
footer: `<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||
<button class="btn btn-primary" onclick="submitScoring(${id})">Calcola e Salva</button>`
|
||
});
|
||
updateScorePreview();
|
||
} catch (e) {
|
||
showNotification('Errore nell\'apertura della valutazione.', 'error');
|
||
}
|
||
}
|
||
|
||
function updateScorePreview() {
|
||
let total = 0, complete = true;
|
||
let rows = '';
|
||
document.querySelectorAll('.score-criterion').forEach(s => {
|
||
const meaningEl = document.getElementById('meaning-' + s.dataset.key);
|
||
if (!s.value) {
|
||
complete = false;
|
||
if (meaningEl) meaningEl.textContent = '';
|
||
return;
|
||
}
|
||
const opt = s.selectedOptions[0];
|
||
const pts = parseInt(opt.dataset.pts || '0', 10);
|
||
total += pts;
|
||
// #432: mostra il significato dell'opzione selezionata sotto la combo
|
||
if (meaningEl) meaningEl.textContent = opt.dataset.desc || '';
|
||
// #431: dettaglio per criterio del punteggio raggiunto
|
||
rows += `<div style="display:flex;justify-content:space-between;gap:0.5rem;padding:3px 0;border-bottom:1px solid var(--gray-100);">
|
||
<span>${escapeHtml(s.dataset.critLabel)}: ${escapeHtml(opt.dataset.lbl || '')}</span>
|
||
<span style="font-weight:600;white-space:nowrap;">${pts}/${s.dataset.max}</span></div>`;
|
||
});
|
||
let cls = total >= 80 ? 'critico' : total >= 60 ? 'alto' : total >= 40 ? 'medio' : total >= 20 ? 'basso' : 'trascurabile';
|
||
const color = relevanceClassColors[cls];
|
||
document.getElementById('score-total').textContent = total;
|
||
const clsEl = document.getElementById('score-class');
|
||
clsEl.textContent = complete ? `${cls.charAt(0).toUpperCase() + cls.slice(1)}${total >= 40 ? ' — Rilevante NIS2 ✓' : ''}` : '(completa tutti i criteri)';
|
||
clsEl.style.color = complete ? color : 'var(--gray-400)';
|
||
const bdEl = document.getElementById('score-breakdown');
|
||
if (bdEl) bdEl.innerHTML = rows
|
||
? `<div style="font-weight:600;margin-bottom:4px;">Dettaglio punteggio per criterio</div>${rows}`
|
||
: '';
|
||
}
|
||
|
||
async function submitScoring(id) {
|
||
const criteria = {};
|
||
let complete = true;
|
||
document.querySelectorAll('.score-criterion').forEach(s => {
|
||
if (!s.value) complete = false;
|
||
criteria[s.dataset.key] = s.value;
|
||
});
|
||
if (!complete) { showNotification('Compila tutti i 6 criteri.', 'warning'); return; }
|
||
try {
|
||
const r = await api.scoreAsset(id, criteria);
|
||
if (r.success) {
|
||
showNotification(`Rilevanza calcolata: ${r.data.score}/100 (${r.data.class}).`, 'success');
|
||
closeModal();
|
||
loadAssets();
|
||
} else {
|
||
showNotification(r.message || 'Errore nel calcolo.', 'error');
|
||
}
|
||
} catch (e) {
|
||
showNotification('Errore di connessione.', 'error');
|
||
}
|
||
}
|
||
|
||
// ── Asset Detail View ───────────────────────────────────
|
||
async function showAssetDetail(id) {
|
||
try {
|
||
const result = await api.getAsset(id);
|
||
if (!result.success || !result.data) {
|
||
showNotification('Bene 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 => {
|
||
const dep = (allAssets || []).find(x => x.id === parseInt(d));
|
||
return `<span class="dep-tag">${dep ? escapeHtml(dep.name) : ('ID: ' + escapeHtml(String(d)))}</span>`;
|
||
}).join('');
|
||
}
|
||
}
|
||
|
||
showModal(escapeHtml(a.name), `
|
||
<div class="detail-grid">
|
||
<div class="detail-field">
|
||
<div class="detail-field-label">Voce / sottoclasse</div>
|
||
<div class="detail-field-value">${escapeHtml(assetTipo(a))}</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-danger" onclick="confirmDeleteAsset(${a.id})">Elimina</button>
|
||
<button class="btn btn-secondary" onclick="closeModal(); showScoringModal(${a.id})">Valuta Rilevanza NIS2</button>
|
||
<button class="btn btn-primary" onclick="closeModal(); showEditAssetModal(${a.id})">Modifica</button>
|
||
`
|
||
});
|
||
} catch (e) {
|
||
showNotification('Errore nel caricamento del bene.', 'error');
|
||
}
|
||
}
|
||
|
||
// ── Create Asset Modal ──────────────────────────────────
|
||
function showCreateAssetModal() {
|
||
showAssetModal('Nuovo bene', {}, async function(data) {
|
||
try {
|
||
const result = await api.createAsset(data);
|
||
if (result.success) {
|
||
showNotification('Bene 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('Bene non trovato.', 'error');
|
||
return;
|
||
}
|
||
|
||
showAssetModal('Modifica bene', result.data, async function(data) {
|
||
try {
|
||
const res = await api.updateAsset(id, data);
|
||
if (res.success) {
|
||
showNotification('Bene 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 voceSel = a.voce_code || 'ID.AM-01';
|
||
const voceOpts = voceOptionsHtml(a.voce_code);
|
||
const subOpts = subclassOptionsHtml(voceSel, a.subclass_id);
|
||
|
||
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('');
|
||
|
||
// Dipendenze (mappa delle dipendenze): opzioni = tutti gli altri beni
|
||
let curDeps = a.dependencies;
|
||
if (typeof curDeps === 'string') { try { curDeps = JSON.parse(curDeps); } catch (e) { curDeps = []; } }
|
||
curDeps = (curDeps || []).map(x => parseInt(x));
|
||
const depOptions = (allAssets || [])
|
||
.filter(x => x.id !== a.id)
|
||
.map(x => `<option value="${x.id}" ${curDeps.includes(x.id) ? 'selected' : ''}>${escapeHtml(x.name)}</option>`)
|
||
.join('') || '<option value="" disabled>Nessun altro bene disponibile in inventario</option>';
|
||
|
||
// Store the callback
|
||
window._assetSaveCallback = onSave;
|
||
|
||
showModal(title, `
|
||
<div class="form-group">
|
||
<label for="asset-name" 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 for="asset-voce" class="form-label">Voce inventario *</label>
|
||
<select class="form-select" id="asset-voce" onchange="onVoceChange()">${voceOpts}</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="asset-subclass" class="form-label">Sottoclasse
|
||
<a href="#" onclick="addSubclassPrompt();return false;" style="font-weight:400;font-size:.78rem;margin-left:6px;">+ aggiungi</a>
|
||
<a href="#" onclick="renameSubclassPrompt();return false;" style="font-weight:400;font-size:.78rem;margin-left:8px;">✎ rinomina</a>
|
||
<a href="#" onclick="removeSubclassPrompt();return false;" style="font-weight:400;font-size:.78rem;margin-left:8px;">🗑 rimuovi</a>
|
||
</label>
|
||
<select class="form-select" id="asset-subclass">${subOpts}</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="asset-category" class="form-label">Categoria (etichetta libera)</label>
|
||
<input type="text" class="form-input" id="asset-category" value="${escapeHtml(a.category || '')}" placeholder="Opzionale: nota libera">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="asset-description" 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 for="asset-criticality" class="form-label">Criticita'</label>
|
||
<select class="form-select" id="asset-criticality">${critOptions}</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="asset-status" 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 for="asset-owner" 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 for="asset-location" 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 for="asset-ip" 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 for="asset-vendor" 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 for="asset-version" class="form-label">Versione</label>
|
||
<input type="text" class="form-input" id="asset-version" value="${escapeHtml(a.version || '')}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="asset-serial" 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 for="asset-purchase-date" 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 for="asset-warranty" class="form-label">Scadenza Garanzia</label>
|
||
<input type="date" class="form-input" id="asset-warranty" value="${a.warranty_expiry || ''}">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="asset-dependencies" class="form-label">Dipendenze (mappa delle dipendenze)</label>
|
||
<select class="form-select" id="asset-dependencies" multiple size="5" aria-describedby="dep-help">${depOptions}</select>
|
||
<small id="dep-help" class="form-text text-muted" style="display:block;margin-top:4px;">Seleziona i beni da cui questo bene dipende (tieni premuto Ctrl/Cmd per selezionarne piu' di uno). Alimenta la Mappa delle Dipendenze e supporta l'analisi del rischio (art. 24 D.Lgs. 138/2024).</small>
|
||
</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,
|
||
voce_code: document.getElementById('asset-voce').value,
|
||
subclass_id: document.getElementById('asset-subclass').value || null,
|
||
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,
|
||
};
|
||
|
||
const depSel = document.getElementById('asset-dependencies');
|
||
data.dependencies = depSel
|
||
? Array.from(depSel.selectedOptions).map(o => parseInt(o.value)).filter(n => !isNaN(n))
|
||
: [];
|
||
|
||
if (window._assetSaveCallback) {
|
||
window._assetSaveCallback(data);
|
||
}
|
||
}
|
||
|
||
// ── Elimina bene ────────────────────────────────────────
|
||
async function confirmDeleteAsset(id) {
|
||
const a = (allAssets || []).find(x => x.id === parseInt(id));
|
||
const nome = a ? a.name : ('#' + id);
|
||
if (!confirm(`Eliminare definitivamente il bene "${nome}"?\nL'operazione non e' reversibile.`)) return;
|
||
try {
|
||
const r = await api.deleteAsset(id);
|
||
if (r.success) {
|
||
showNotification('Bene eliminato dall\'inventario.', 'success');
|
||
closeModal();
|
||
loadAssets();
|
||
} else {
|
||
showNotification(r.message || 'Eliminazione non riuscita (serve il ruolo amministratore).', 'error');
|
||
}
|
||
} catch (e) {
|
||
showNotification('Errore di connessione.', 'error');
|
||
}
|
||
}
|
||
|
||
// ── Mappa delle Dipendenze ──────────────────────────────
|
||
function parseAssetDeps(a) {
|
||
let d = a.dependencies;
|
||
if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { d = []; } }
|
||
return (d || []).map(x => parseInt(x)).filter(n => !isNaN(n));
|
||
}
|
||
function showDependencyMap() {
|
||
const assets = allAssets || [];
|
||
const byId = {};
|
||
assets.forEach(a => { byId[a.id] = a; });
|
||
const total = assets.reduce((s, a) => s + parseAssetDeps(a).length, 0);
|
||
let body;
|
||
if (!assets.length) {
|
||
body = `<p style="font-size:.9rem;color:var(--gray-600);">Nessun bene in inventario. Aggiungi prima i beni, poi apri <strong>Modifica</strong> su ciascuno per impostare le dipendenze.</p>`;
|
||
} else {
|
||
const rows = assets.map(a => {
|
||
const deps = parseAssetDeps(a);
|
||
const depNames = deps.length
|
||
? deps.map(id => `<span class="dep-tag">${byId[id] ? escapeHtml(byId[id].name) : ('#' + id)}</span>`).join(' ')
|
||
: '<span style="color:var(--gray-400);">— nessuna dipendenza</span>';
|
||
return `<tr><td style="font-weight:500;">${escapeHtml(a.name)}</td><td>${depNames}</td></tr>`;
|
||
}).join('');
|
||
body = `<p style="font-size:.85rem;color:var(--gray-600);margin-bottom:.8rem;">Relazioni di dipendenza tra i beni dell'inventario (${total} collegamenti totali). Per impostare o modificare le dipendenze di un bene, aprilo con <strong>Modifica</strong> e usa il campo <em>Dipendenze</em>. La mappa supporta l'analisi del rischio e la continuita' operativa (art. 24 D.Lgs. 138/2024).</p>
|
||
<table class="data-table"><thead><tr><th>Bene</th><th>Dipende da</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||
}
|
||
showModal('Mappa delle Dipendenze', body, {
|
||
size: 'lg',
|
||
footer: `<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>`
|
||
});
|
||
}
|
||
|
||
// ── Initial Load ────────────────────────────────────────
|
||
loadAssets();
|
||
|
||
/* ── Import asset CMDB/CSV (P2) ───────────────────────────── */
|
||
function openImportModal(){
|
||
const body = `
|
||
<p style="font-size:.85rem;color:var(--gray-600);margin-bottom:10px;">Incolla CSV o carica un file. Colonne riconosciute:
|
||
<code>name, asset_type, criticality, data_classification, internet_facing, dependencies_count, regulated, external_ref, vendor, location</code>.
|
||
Lo scoring di rilevanza NIS2 (GV.OC-04) viene calcolato automaticamente.</p>
|
||
<div class="form-group"><input type="file" accept=".csv,text/csv" id="imp-file" onchange="impFile(event)" aria-label="Seleziona file CSV da importare"></div>
|
||
<div class="form-group"><label for="imp-csv" class="form-label">Oppure incolla CSV (prima riga = intestazioni)</label>
|
||
<textarea class="form-input" id="imp-csv" rows="8" placeholder="name,asset_type,criticality,data_classification,internet_facing,dependencies_count,regulated
|
||
Core ERP,software,critical,financial,false,8,true
|
||
Web Portal,service,high,personal,true,3,true"></textarea></div>
|
||
<div id="imp-preview" style="font-size:.8rem;color:var(--gray-600);"></div>`;
|
||
showModal('Importa beni', body, {
|
||
size:'lg',
|
||
footer:`<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||
<button class="btn btn-primary" onclick="doImport()">Importa</button>`
|
||
});
|
||
}
|
||
function impFile(e){
|
||
const f=e.target.files[0]; if(!f)return;
|
||
const r=new FileReader(); r.onload=()=>{document.getElementById('imp-csv').value=r.result;}; r.readAsText(f);
|
||
}
|
||
// Split di una riga CSV gestendo virgolette ed escape (""), così nomi con
|
||
// virgola/virgolette importano correttamente (round-trip con l'export). Le righe
|
||
// non quotate si comportano come prima (retro-compatibile).
|
||
function splitCsvLine(line){
|
||
const out=[]; let cur=''; let inQ=false;
|
||
for(let i=0;i<line.length;i++){
|
||
const ch=line[i];
|
||
if(inQ){
|
||
if(ch==='"'){ if(line[i+1]==='"'){ cur+='"'; i++; } else inQ=false; }
|
||
else cur+=ch;
|
||
} else {
|
||
if(ch==='"') inQ=true;
|
||
else if(ch===',') { out.push(cur); cur=''; }
|
||
else cur+=ch;
|
||
}
|
||
}
|
||
out.push(cur);
|
||
return out;
|
||
}
|
||
function parseCsv(text){
|
||
const lines=text.trim().split(/\r?\n/).filter(l=>l.trim());
|
||
if(lines.length<2)return [];
|
||
const headers=splitCsvLine(lines[0]).map(h=>h.trim().toLowerCase());
|
||
const truthy=v=>/^(1|true|si|sì|yes|y)$/i.test((v||'').trim());
|
||
return lines.slice(1).map(line=>{
|
||
const cells=splitCsvLine(line); const o={};
|
||
headers.forEach((h,i)=>{ o[h]=(cells[i]||'').trim(); });
|
||
const a={name:o.name, asset_type:o.asset_type, criticality:o.criticality,
|
||
data_classification:o.data_classification, vendor:o.vendor, location:o.location,
|
||
external_ref:o.external_ref};
|
||
if('internet_facing' in o) a.internet_facing=truthy(o.internet_facing);
|
||
if('regulated' in o) a.regulated=truthy(o.regulated);
|
||
if('dependencies_count' in o) a.dependencies_count=parseInt(o.dependencies_count)||0;
|
||
return a;
|
||
}).filter(a=>a.name);
|
||
}
|
||
async function doImport(){
|
||
const assets=parseCsv(document.getElementById('imp-csv').value);
|
||
if(!assets.length){showNotification('Nessuna riga valida (serve almeno name)','warning');return;}
|
||
try{
|
||
const res=await api.importAssets({source:'csv', assets});
|
||
if(res.success){
|
||
const d=res.data;
|
||
closeModal();
|
||
showNotification(`Import: ${d.imported} nuovi, ${d.updated} aggiornati, ${d.relevant} rilevanti NIS2, ${d.skipped} scartati`,'success');
|
||
if(typeof loadAssets==='function')loadAssets();
|
||
} else showNotification(res.message||'Errore import','error');
|
||
}catch(e){showNotification('Errore di connessione','error');}
|
||
}
|
||
|
||
/* ── Export inventario (stesso formato dell'import) #441 ──── */
|
||
// Stesse colonne riconosciute dall'import: re-importando il file si ricostruisce
|
||
// l'inventario (lo scoring di rilevanza NIS2 viene ricalcolato in import).
|
||
const EXPORT_COLS = ['name','asset_type','criticality','data_classification','internet_facing','dependencies_count','regulated','external_ref','vendor','location'];
|
||
function csvCell(v){
|
||
const s = (v===null||v===undefined) ? '' : String(v).replace(/[\r\n]+/g,' ');
|
||
return /[",]/.test(s) ? '"'+s.replace(/"/g,'""')+'"' : s;
|
||
}
|
||
// Ricostruisce le colonne di import dai criteri di scoring salvati (relevance_criteria).
|
||
// Le coppie sono reversibili: re-importando si riottengono gli stessi criteri.
|
||
function assetToExportRow(a){
|
||
let crit = {};
|
||
try { crit = (typeof a.relevance_criteria === 'string') ? JSON.parse(a.relevance_criteria||'{}') : (a.relevance_criteria||{}); } catch(e){ crit = {}; }
|
||
const dcMap = { gdpr_art9:'special', personal_large:'personal', personal_fin:'financial', confidential:'confidential', public:'public' };
|
||
const depMap = { ge5_critical:5, n3_4_critical:3, n2_critical:2, n1_critical:1, none:0 };
|
||
const c4 = crit.c4_dependencies;
|
||
return {
|
||
name: a.name || '',
|
||
asset_type: a.asset_type || '',
|
||
criticality: a.criticality || '',
|
||
data_classification: dcMap[crit.c3_data_processed] || '',
|
||
internet_facing: /^internet/.test(crit.c5_exposure || '') ? 'true' : 'false',
|
||
dependencies_count: (c4 in depMap) ? depMap[c4] : 0,
|
||
regulated: (crit.c6_regulatory && crit.c6_regulatory !== 'none') ? 'true' : 'false',
|
||
external_ref: a.external_ref || '',
|
||
vendor: a.vendor || '',
|
||
location: a.location || ''
|
||
};
|
||
}
|
||
async function exportAssets(){
|
||
showNotification('Preparazione esportazione…','info');
|
||
let items=[], page=1, total=Infinity;
|
||
try{
|
||
while(items.length < total){
|
||
const res = await api.listAssets({ per_page:200, page });
|
||
if(!res || !res.success){ showNotification((res&&res.message)||'Errore durante l\'esportazione','error'); return; }
|
||
const d = res.data || {};
|
||
const batch = d.items || (Array.isArray(d) ? d : []);
|
||
total = (typeof d.total === 'number') ? d.total : batch.length;
|
||
items = items.concat(batch);
|
||
if(!batch.length || page>50) break;
|
||
page++;
|
||
}
|
||
}catch(e){ showNotification('Errore di connessione durante l\'esportazione','error'); return; }
|
||
if(!items.length){ showNotification('Inventario vuoto: niente da esportare','warning'); return; }
|
||
const lines = [ EXPORT_COLS.join(',') ];
|
||
items.map(assetToExportRow).forEach(r => lines.push(EXPORT_COLS.map(c => csvCell(r[c])).join(',')));
|
||
const csv = '' + lines.join('\r\n'); // BOM per Excel
|
||
const stamp = new Date().toISOString().slice(0,10);
|
||
const blob = new Blob([csv], { type:'text/csv;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const el = document.createElement('a');
|
||
el.href = url; el.download = `inventario-nis2-${stamp}.csv`;
|
||
document.body.appendChild(el); el.click(); el.remove(); URL.revokeObjectURL(url);
|
||
showNotification(`Esportati ${items.length} beni nel formato di importazione`,'success');
|
||
}
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|