[FEAT] Supply chain UI: dropdown categoria + gestione categorie + vista template GV.SC
Colma il gap UI confermato dalla review (backend Fase 1 c'era, UI no):
- Form fornitore: dropdown "Categoria fornitore" alimentato da getSupplierCategories
(ensureCategories con cache); category_id ora salvato (backend gia' fixato 9fbf72a).
- Pulsante "Categorie": modale con lista preset+custom, CRUD sulle custom
(preset non modificabili), gestione errore CATEGORY_IN_USE.
- Pulsante "Template": modale lista template + vista dettaglio domande read-only
con badge nis2_ref/tipo/peso/obbligatoria — mostra le 26 domande GV.SC del DB.
- Target modale via querySelector('#app-modal .modal-body') (showModal usa class).
Inline JS validato (node --check exit 0). File statici -> live via nginx. version 1.10.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f2ee6801e1
commit
ce0387ed4f
@ -370,6 +370,8 @@
|
|||||||
<header class="content-header">
|
<header class="content-header">
|
||||||
<h2 data-i18n="supply_chain.title">Sicurezza Supply Chain</h2>
|
<h2 data-i18n="supply_chain.title">Sicurezza Supply Chain</h2>
|
||||||
<div class="content-header-actions">
|
<div class="content-header-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="openCategoriesModal()"><i class="fas fa-tags"></i> Categorie</button>
|
||||||
|
<button class="btn btn-secondary" onclick="openTemplatesModal()"><i class="fas fa-clipboard-list"></i> Template</button>
|
||||||
<button class="btn btn-secondary" onclick="openImportModal()"><i class="fas fa-file-import"></i> Importa</button>
|
<button class="btn btn-secondary" onclick="openImportModal()"><i class="fas fa-file-import"></i> Importa</button>
|
||||||
<button class="btn btn-primary" onclick="openCreateModal()">+ Nuovo Fornitore</button>
|
<button class="btn btn-primary" onclick="openCreateModal()">+ Nuovo Fornitore</button>
|
||||||
</div>
|
</div>
|
||||||
@ -872,12 +874,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Create / Edit Modal ─────────────────────────────────
|
// ── Create / Edit Modal ─────────────────────────────────
|
||||||
function openCreateModal() {
|
let allCategories = []; // cache categorie (preset org 0 + custom org)
|
||||||
|
|
||||||
|
async function ensureCategories() {
|
||||||
|
if (allCategories.length > 0) return;
|
||||||
|
try {
|
||||||
|
const res = await api.getSupplierCategories();
|
||||||
|
if (res.success) allCategories = res.data || [];
|
||||||
|
} catch (e) { /* non bloccante: la modale apre comunque */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreateModal() {
|
||||||
|
await ensureCategories();
|
||||||
showSupplierFormModal(null);
|
showSupplierFormModal(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openEditModal(id) {
|
async function openEditModal(id) {
|
||||||
try {
|
try {
|
||||||
|
await ensureCategories();
|
||||||
const result = await api.getSupplier(id);
|
const result = await api.getSupplier(id);
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
showSupplierFormModal(result.data);
|
showSupplierFormModal(result.data);
|
||||||
@ -897,6 +911,11 @@
|
|||||||
`<option value="${val}" ${supplier && supplier.criticality === val ? 'selected' : ''}>${label}</option>`
|
`<option value="${val}" ${supplier && supplier.criticality === val ? 'selected' : ''}>${label}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
|
const categoryOptions = '<option value="">— Nessuna —</option>' +
|
||||||
|
allCategories.map(c =>
|
||||||
|
`<option value="${c.id}" ${supplier && String(supplier.category_id) === String(c.id) ? 'selected' : ''}>${escapeHtml(c.name)}${Number(c.is_system) === 1 ? '' : ' (personalizzata)'}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
<form id="supplier-form" onsubmit="return false;">
|
<form id="supplier-form" onsubmit="return false;">
|
||||||
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||||
@ -931,6 +950,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="form-category">Categoria fornitore</label>
|
||||||
|
<select class="form-select" id="form-category">
|
||||||
|
${categoryOptions}
|
||||||
|
</select>
|
||||||
|
<small style="color:var(--gray-500);font-size:0.75rem;">Classifica il fornitore (es. Cloud Provider). Gestisci le categorie dalla tab "Categorie".</small>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Descrizione Servizio</label>
|
<label class="form-label">Descrizione Servizio</label>
|
||||||
<textarea class="form-textarea" id="form-service-desc" rows="3" placeholder="Descrivi il servizio fornito...">${isEdit && supplier.service_description ? escapeHtml(supplier.service_description) : ''}</textarea>
|
<textarea class="form-textarea" id="form-service-desc" rows="3" placeholder="Descrivi il servizio fornito...">${isEdit && supplier.service_description ? escapeHtml(supplier.service_description) : ''}</textarea>
|
||||||
@ -982,6 +1008,7 @@
|
|||||||
service_type: serviceType,
|
service_type: serviceType,
|
||||||
service_description: document.getElementById('form-service-desc').value.trim() || null,
|
service_description: document.getElementById('form-service-desc').value.trim() || null,
|
||||||
criticality: document.getElementById('form-criticality').value,
|
criticality: document.getElementById('form-criticality').value,
|
||||||
|
category_id: (document.getElementById('form-category') && document.getElementById('form-category').value) || null,
|
||||||
contract_start_date: document.getElementById('form-contract-start').value || null,
|
contract_start_date: document.getElementById('form-contract-start').value || null,
|
||||||
contract_expiry_date: document.getElementById('form-contract-expiry').value || null,
|
contract_expiry_date: document.getElementById('form-contract-expiry').value || null,
|
||||||
notes: document.getElementById('form-notes').value.trim() || null,
|
notes: document.getElementById('form-notes').value.trim() || null,
|
||||||
@ -1264,6 +1291,179 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// CATEGORIE FORNITORE (preset di sistema + custom per-org)
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
async function openCategoriesModal() {
|
||||||
|
await ensureCategories();
|
||||||
|
renderCategoriesModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategoriesModal() {
|
||||||
|
const rows = allCategories.map(c => {
|
||||||
|
const preset = Number(c.is_system) === 1;
|
||||||
|
const badge = preset
|
||||||
|
? '<span class="badge badge-neutral" title="Categoria di sistema">Preset</span>'
|
||||||
|
: '<span class="badge badge-info">Personalizzata</span>';
|
||||||
|
const actions = preset ? '<span style="color:var(--gray-400);font-size:0.75rem;">non modificabile</span>' : `
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="editCategory(${c.id})">Modifica</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="removeCategory(${c.id})">Elimina</button>`;
|
||||||
|
return `<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border-bottom:1px solid var(--gray-100);flex-wrap:wrap;">
|
||||||
|
<div style="flex:1;min-width:200px;">
|
||||||
|
<div style="font-weight:600;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">${escapeHtml(c.name)} ${badge}</div>
|
||||||
|
${c.description ? `<div style="font-size:0.78rem;color:var(--gray-500);margin-top:2px;">${escapeHtml(c.description)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;flex-shrink:0;">${actions}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
const content = `
|
||||||
|
<div style="display:flex;justify-content:flex-end;margin-bottom:10px;">
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="newCategory()">+ Nuova categoria</button>
|
||||||
|
</div>
|
||||||
|
<div id="categories-list-body">${rows || '<p style="color:var(--gray-500);">Nessuna categoria.</p>'}</div>
|
||||||
|
<p style="font-size:0.75rem;color:var(--gray-500);margin-top:12px;">Le categorie <strong>preset</strong> sono fornite dal sistema e non sono modificabili. Puoi creare categorie personalizzate per la tua organizzazione.</p>`;
|
||||||
|
showModal('Categorie fornitore', content, {
|
||||||
|
size: 'lg',
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function newCategory() { categoryForm(null); }
|
||||||
|
function editCategory(id) { categoryForm(allCategories.find(c => c.id === id) || null); }
|
||||||
|
|
||||||
|
function categoryForm(cat) {
|
||||||
|
const isEdit = !!cat;
|
||||||
|
const content = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="cat-name">Nome categoria <span class="required">*</span></label>
|
||||||
|
<input type="text" class="form-input" id="cat-name" value="${isEdit ? escapeHtml(cat.name) : ''}" placeholder="es. Cloud Provider">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="cat-desc">Descrizione</label>
|
||||||
|
<textarea class="form-textarea" id="cat-desc" rows="3" placeholder="Descrizione opzionale...">${isEdit && cat.description ? escapeHtml(cat.description) : ''}</textarea>
|
||||||
|
</div>`;
|
||||||
|
showModal(isEdit ? 'Modifica categoria' : 'Nuova categoria', content, {
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="openCategoriesModal()">Annulla</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveCategory(${isEdit ? cat.id : 'null'})">${isEdit ? 'Salva' : 'Crea'}</button>`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCategory(id) {
|
||||||
|
const name = document.getElementById('cat-name').value.trim();
|
||||||
|
if (!name) { showNotification('Il nome della categoria e\' obbligatorio.', 'warning'); return; }
|
||||||
|
const data = { name, description: document.getElementById('cat-desc').value.trim() || null };
|
||||||
|
try {
|
||||||
|
const res = id ? await api.updateSupplierCategory(id, data) : await api.createSupplierCategory(data);
|
||||||
|
if (res.success) {
|
||||||
|
allCategories = []; // invalida cache
|
||||||
|
await ensureCategories();
|
||||||
|
showNotification(id ? 'Categoria aggiornata.' : 'Categoria creata.', 'success');
|
||||||
|
renderCategoriesModal();
|
||||||
|
} else {
|
||||||
|
showNotification(res.message || 'Errore nel salvataggio.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) { showNotification('Errore di connessione.', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCategory(id) {
|
||||||
|
const cat = allCategories.find(c => c.id === id);
|
||||||
|
if (!confirm('Eliminare la categoria "' + (cat ? cat.name : '') + '"?')) return;
|
||||||
|
try {
|
||||||
|
const res = await api.deleteSupplierCategory(id);
|
||||||
|
if (res.success) {
|
||||||
|
allCategories = [];
|
||||||
|
await ensureCategories();
|
||||||
|
showNotification('Categoria eliminata.', 'success');
|
||||||
|
renderCategoriesModal();
|
||||||
|
} else {
|
||||||
|
showNotification(res.message || 'Impossibile eliminare (categoria in uso?).', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) { showNotification('Errore di connessione.', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// TEMPLATE QUESTIONARI (lista + dettaglio domande, read-only)
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
const QTYPE_LABELS = {
|
||||||
|
yes_no_partial: 'Si/Parziale/No', single_choice: 'Scelta singola',
|
||||||
|
multi_choice: 'Scelta multipla', scale_1_5: 'Scala 1-5',
|
||||||
|
text: 'Testo', number: 'Numero', file: 'Allegato'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function openTemplatesModal() {
|
||||||
|
showModal('Template questionari', '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento...</span></div>', {
|
||||||
|
size: 'lg', footer: `<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>`
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await api.getQuestionnaireTemplates();
|
||||||
|
const list = (res.success && res.data) ? res.data : [];
|
||||||
|
renderTemplatesModal(list);
|
||||||
|
} catch (e) {
|
||||||
|
const b = document.querySelector('#app-modal .modal-body');
|
||||||
|
if (b) b.innerHTML = '<p style="color:var(--danger);">Errore di connessione.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplatesModal(templates) {
|
||||||
|
const b = document.querySelector('#app-modal .modal-body');
|
||||||
|
if (!b) return;
|
||||||
|
if (!templates.length) {
|
||||||
|
b.innerHTML = '<p style="color:var(--gray-500);">Nessun template questionario per questa organizzazione.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
b.innerHTML = templates.map(t => {
|
||||||
|
const qc = Number(t.question_count || 0);
|
||||||
|
const st = t.status === 'active' ? '<span class="badge badge-success">Attivo</span>' : `<span class="badge badge-neutral">${escapeHtml(t.status || 'bozza')}</span>`;
|
||||||
|
return `<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border-bottom:1px solid var(--gray-100);flex-wrap:wrap;">
|
||||||
|
<div style="flex:1;min-width:200px;">
|
||||||
|
<div style="font-weight:600;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">${escapeHtml(t.name)} ${Number(t.is_default)===1?'<span class="badge badge-neutral">Default</span>':''} ${st}</div>
|
||||||
|
<div style="font-size:0.78rem;color:var(--gray-500);margin-top:2px;">${t.category_name?escapeHtml(t.category_name)+' · ':''}${qc} domand${qc===1?'a':'e'}${t.current_version?' · v'+escapeHtml(String(t.current_version)):''}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="viewTemplate(${t.id})">Vedi domande</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewTemplate(id) {
|
||||||
|
const b = document.querySelector('#app-modal .modal-body');
|
||||||
|
if (b) b.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento domande...</span></div>';
|
||||||
|
try {
|
||||||
|
const res = await api.getQuestionnaireTemplate(id);
|
||||||
|
if (res.success && res.data) renderTemplateDetail(res.data);
|
||||||
|
else if (b) b.innerHTML = '<p style="color:var(--danger);">Template non trovato.</p>';
|
||||||
|
} catch (e) {
|
||||||
|
if (b) b.innerHTML = '<p style="color:var(--danger);">Errore di connessione.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplateDetail(tpl) {
|
||||||
|
const b = document.querySelector('#app-modal .modal-body');
|
||||||
|
if (!b) return;
|
||||||
|
const qs = tpl.questions || [];
|
||||||
|
const qHtml = qs.length ? qs.map((q, i) => `
|
||||||
|
<div style="padding:10px 0;border-bottom:1px solid var(--gray-100);">
|
||||||
|
<div style="display:flex;gap:8px;align-items:flex-start;">
|
||||||
|
<span style="font-weight:700;color:var(--gray-400);font-size:0.82rem;min-width:24px;">${i+1}.</span>
|
||||||
|
<span style="font-size:0.875rem;color:var(--gray-800);font-weight:500;flex:1;">${escapeHtml(q.question_text)}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px;padding-left:24px;">
|
||||||
|
${q.nis2_ref?`<span class="badge badge-info" title="Riferimento normativo">${escapeHtml(q.nis2_ref)}</span>`:''}
|
||||||
|
<span class="badge badge-neutral">${escapeHtml(QTYPE_LABELS[q.question_type]||q.question_type||'')}</span>
|
||||||
|
${q.weight!=null?`<span class="badge badge-neutral">Peso ${escapeHtml(String(q.weight))}</span>`:''}
|
||||||
|
${Number(q.is_required)===1?'<span class="badge badge-neutral">Obbligatoria</span>':''}
|
||||||
|
${Number(q.high_criticality_only)===1?'<span class="badge badge-warning">Solo alta criticita</span>':''}
|
||||||
|
</div>
|
||||||
|
</div>`).join('') : '<p style="color:var(--gray-500);">Nessuna domanda nel template.</p>';
|
||||||
|
b.innerHTML = `
|
||||||
|
<div style="margin-bottom:12px;"><button class="btn btn-sm btn-secondary" onclick="openTemplatesModal()">← Torna ai template</button></div>
|
||||||
|
<h3 style="margin:0 0 4px;">${escapeHtml(tpl.name)}</h3>
|
||||||
|
<div style="font-size:0.8rem;color:var(--gray-500);margin-bottom:12px;">${qs.length} domand${qs.length===1?'a':'e'}${tpl.current_version?' · versione '+escapeHtml(String(tpl.current_version)):''}${tpl.pass_threshold!=null?' · soglia '+escapeHtml(String(tpl.pass_threshold))+'%':''}</div>
|
||||||
|
${tpl.description?`<p style="font-size:0.85rem;color:var(--gray-600);margin-bottom:12px;">${escapeHtml(tpl.description)}</p>`:''}
|
||||||
|
<div style="background:var(--info-bg,#eff6ff);border-radius:6px;padding:10px 12px;margin-bottom:12px;font-size:0.78rem;color:var(--primary);">Sola lettura. L'editor delle domande sara' disponibile in una prossima versione.</div>
|
||||||
|
${qHtml}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Init ────────────────────────────────────────────────
|
// ── Init ────────────────────────────────────────────────
|
||||||
loadSuppliers();
|
loadSuppliers();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
{"version":"1.9.2","build":"2026-05-31-v1.9.2","date":"2026-05-31","changelog":"Fase 1 fornitori + hardening post-review: fix incidents.html (SyntaxError apostrofi), dashboard gauge compliance, citazioni ACN GV.SC con disclaimer interpretazioni nel template, scope org category_id import. Guida: avvertenza generale (non parere legale) + capitolo fornitori (import CSV/API, categorie, template GV.SC, nota interpretativa perimetro). Help online allineato."}
|
{"version":"1.10.0","build":"2026-05-31-v1.10.0","date":"2026-05-31","changelog":"UI modulo questionari fornitori: dropdown categoria nel form fornitore, gestione categorie (preset+custom CRUD), visualizzazione template questionari con domande GV.SC e badge nis2_ref (read-only). Backend Fase 1 ora pienamente usabile dalla UI."}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user