[FEAT] Epic C / C4 — inventario a 2 voci + sottoclassi configurabili (mig.050)

Simon C4: nell'inventario DUE sole voci principali; le sottoclassi le crea l'utente.
Riconduzione concordata (no perdita di dettaglio): hardware/network/facility -> ID.AM-01;
software/service/data -> ID.AM-02; personnel -> Organigramma (0 asset personnel presenti).

- Config: cfg_inventory_voci (2: ID.AM-01 Hardware, ID.AM-02 Software/Servizi/Sistemi),
  inventory_subclassi (12 default di sistema org NULL + sottoclassi org-scoped).
- assets.voce_code + subclass_id (FK). 20 asset esistenti MIGRATI (5 ID.AM-01 / 15 ID.AM-02).
- AssetController: list con label voce/sottoclasse; create/update validati (voce in whitelist,
  sottoclasse appartenente a org/default+voce); GET/POST /assets/subclasses; import (bulkUpsert)
  deriva la voce dal tipo legacy. asset_type resta come campo legacy (viste secondarie).
- assets.html: filtro per voce, form con selettore Voce + Sottoclasse (+ '+ aggiungi'),
  lista/dettaglio mostrano 'voce · sottoclasse', stat-chips per voce.
- FIX bug pre-esistente: apostrofo non escapato ('l'inventario') che ROMPEVA tutto il JS
  della pagina Inventario. + cache-buster common-bi.js (era a v=20260618, non bumpato dal fix C3).
Help + KB + i18n. Verifiche prod: subclasses 200, create voce/legacy OK, validazione 422,
JS valido (node --check), migrazione idempotente. version 1.18.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-16 19:59:35 +02:00
co-authored by Claude Opus 4.8
parent e281aa4970
commit 30f4614b37
41 changed files with 370 additions and 173 deletions
+67 -30
View File
@@ -331,14 +331,9 @@
<!-- Filters -->
<div class="filters-bar">
<select id="filter-type" onchange="applyFilters()" aria-label="Filtra per tipo">
<option value="">Tutti i tipi</option>
<option value="hardware">Hardware</option>
<option value="software">Software</option>
<option value="network">Rete</option>
<option value="data">Dati</option>
<option value="service">Servizio</option>
<option value="personnel">Personale</option>
<option value="facility">Struttura</option>
<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>
@@ -366,8 +361,8 @@
</main>
</div>
<script src="/js/api.js?v=20260624"></script>
<script src="/js/common.js?v=20260624"></script>
<script src="/js/api.js?v=20260625"></script>
<script src="/js/common.js?v=20260625"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
@@ -377,15 +372,16 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260624"></script>
<script src="/js/help.js?v=20260624"></script>
<script src="/js/common-bi.js?v=20260625"></script>
<script src="/js/i18n.js?v=20260625"></script>
<script src="/js/help.js?v=20260625"></script>
<script>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
HelpSystem.init();
loadSubclasses(); // C4 — voci+sottoclassi per i selettori (fire-and-forget, pronto al primo "Nuovo")
// ── Labels ──────────────────────────────────────────────
const typeLabels = {
@@ -411,6 +407,42 @@
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'); }
}
// ── State ───────────────────────────────────────────────
let allAssets = [];
@@ -423,7 +455,6 @@
const type = document.getElementById('filter-type').value;
const criticality = document.getElementById('filter-criticality').value;
const status = document.getElementById('filter-status').value;
if (type) params.asset_type = type;
if (criticality) params.criticality = criticality;
if (status) params.status = status;
@@ -431,13 +462,14 @@
const result = await api.listAssets(params);
if (result.success) {
allAssets = result.data || [];
updateStats(allAssets);
renderAssets(allAssets);
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>';
container.innerHTML = '<div class="empty-state-box"><h4>Errore di connessione</h4><p>Impossibile caricare l\'inventario.</p></div>';
}
}
@@ -455,7 +487,7 @@
let criticalCount = 0;
let decommCount = 0;
assets.forEach(a => {
const t = a.asset_type || 'unknown';
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++;
@@ -469,7 +501,7 @@
// Type chips
const chipsEl = document.getElementById('stat-type-chips');
chipsEl.innerHTML = Object.entries(typeCount).map(([type, count]) =>
`<span class="type-chip"><strong>${count}</strong> ${typeLabels[type] || type}</span>`
`<span class="type-chip"><strong>${count}</strong> ${escapeHtml(type)}</span>`
).join('');
}
@@ -508,7 +540,7 @@
html += `
<tr onclick="showAssetDetail(${asset.id})" title="Visualizza dettagli">
<td style="font-weight:500;">${escapeHtml(asset.name)}</td>
<td>${typeLabels[asset.asset_type] || asset.asset_type || '-'}</td>
<td>${escapeHtml(assetTipo(asset))}</td>
<td>${escapeHtml(asset.category || '-')}</td>
<td><span class="criticality-badge ${crit}">${criticalityLabels[crit] || crit}</span></td>
<td>${relevanceBadge(asset)}</td>
@@ -656,8 +688,8 @@
showModal(escapeHtml(a.name), `
<div class="detail-grid">
<div class="detail-field">
<div class="detail-field-label">Tipo</div>
<div class="detail-field-value">${typeLabels[a.asset_type] || a.asset_type || '-'}</div>
<div 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>
@@ -773,9 +805,9 @@
function showAssetModal(title, asset, onSave) {
const a = asset || {};
const typeOptions = Object.entries(typeLabels).map(([val, label]) =>
`<option value="${val}" ${a.asset_type === val ? 'selected' : ''}>${label}</option>`
).join('');
const 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>`
@@ -795,14 +827,18 @@
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="asset-type" class="form-label">Tipo *</label>
<select class="form-select" id="asset-type">${typeOptions}</select>
<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-category" class="form-label">Categoria</label>
<input type="text" class="form-input" id="asset-category" value="${escapeHtml(a.category || '')}" placeholder="Es: Server, Database...">
<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></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>
@@ -875,7 +911,8 @@
const data = {
name: name,
asset_type: document.getElementById('asset-type').value,
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,