Files

1252 lines
68 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>&ge;${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>