[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:
co-authored by
Claude Opus 4.8
parent
e281aa4970
commit
30f4614b37
+67
-30
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user