nis2-agile/public/supply-chain.html
DevEnv nis2-agile b7f361b87c [FEAT] Supply chain UI: i18n sp.* IT/EN + import mostra errors[] dettaglio righe scartate
- public/js/i18n.js: +38 chiavi sp.* (IT+EN) per pulsanti header, categorie, template, import.
- supply-chain.html: data-i18n sui 4 pulsanti header; l'import CSV ora mostra il
  dettaglio delle righe scartate (d.errors[] dal backend) in un <details> e tiene
  aperta la modale se ci sono errori (prima si chiudeva sempre, errori invisibili).
- version 1.10.1.

Inline JS validato (node --check). File statici -> live via nginx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:37:22 +02:00

1478 lines
75 KiB
HTML

<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Supply Chain - NIS2 Agile</title>
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Supply Chain Page Styles ─────────────────────────── */
.filters-bar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 20px;
}
.filters-bar .form-select {
min-width: 180px;
padding: 8px 12px;
font-size: 0.85rem;
}
.supplier-actions {
display: flex;
gap: 6px;
}
.supplier-actions .btn {
padding: 5px 10px;
font-size: 0.75rem;
}
/* Risk overview cards */
.risk-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.risk-card {
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius);
padding: 20px;
text-align: center;
}
.risk-card-value {
font-size: 2rem;
font-weight: 700;
color: var(--gray-900);
line-height: 1;
}
.risk-card-label {
font-size: 0.78rem;
color: var(--gray-500);
margin-top: 6px;
font-weight: 500;
}
.risk-card-value.danger { color: var(--danger); }
.risk-card-value.warning { color: var(--warning); }
.risk-card-value.success { color: var(--secondary); }
/* Criticality bar chart */
.criticality-chart {
display: flex;
gap: 16px;
align-items: flex-end;
height: 140px;
padding: 16px 0;
}
.chart-bar-group {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.chart-bar {
width: 48px;
border-radius: 4px 4px 0 0;
transition: height 0.5s ease;
min-height: 4px;
position: relative;
}
.chart-bar-value {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
font-weight: 700;
color: var(--gray-700);
}
.chart-bar-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--gray-500);
text-transform: uppercase;
}
/* Risk score progress */
.risk-score-bar {
width: 100%;
height: 8px;
background: var(--gray-200);
border-radius: 4px;
overflow: hidden;
display: inline-flex;
min-width: 80px;
}
.risk-score-fill {
height: 100%;
border-radius: 4px;
transition: width 0.4s ease;
}
.risk-score-cell {
display: flex;
align-items: center;
gap: 8px;
}
.risk-score-value {
font-weight: 600;
font-size: 0.82rem;
min-width: 32px;
text-align: right;
}
/* Detail view */
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
gap: 16px;
}
.detail-header h3 {
font-size: 1.25rem;
color: var(--gray-900);
}
.detail-header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 24px;
}
@media (max-width: 900px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.info-item {
padding: 12px 16px;
border-bottom: 1px solid var(--gray-100);
}
.info-item:nth-child(odd) {
border-right: 1px solid var(--gray-100);
}
.info-item-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--gray-400);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 4px;
}
.info-item-value {
font-size: 0.875rem;
color: var(--gray-800);
font-weight: 500;
}
.meta-card {
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius);
padding: 20px;
margin-bottom: 16px;
}
.meta-card h4 {
font-size: 0.85rem;
font-weight: 600;
color: var(--gray-800);
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--gray-100);
}
.meta-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 0.82rem;
border-bottom: 1px solid var(--gray-50);
}
.meta-row:last-child { border-bottom: none; }
.meta-label { color: var(--gray-500); font-weight: 500; }
.meta-value { color: var(--gray-800); font-weight: 600; text-align: right; }
/* Score gauge (ring) */
.score-ring {
width: 120px;
height: 120px;
margin: 0 auto 12px;
position: relative;
}
.score-ring svg {
transform: rotate(-90deg);
}
.score-ring-bg {
fill: none;
stroke: var(--gray-200);
stroke-width: 8;
}
.score-ring-fill {
fill: none;
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dashoffset 0.6s ease;
}
.score-ring-value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.score-ring-number {
font-size: 1.75rem;
font-weight: 700;
line-height: 1;
}
.score-ring-label {
font-size: 0.65rem;
color: var(--gray-500);
text-transform: uppercase;
font-weight: 600;
}
/* Assessment questions */
.assessment-question {
padding: 14px 0;
border-bottom: 1px solid var(--gray-100);
}
.assessment-question:last-child { border-bottom: none; }
.assessment-question-text {
font-size: 0.875rem;
color: var(--gray-800);
margin-bottom: 10px;
font-weight: 500;
}
.assessment-options {
display: flex;
gap: 8px;
}
.assessment-option {
padding: 6px 16px;
border: 1px solid var(--gray-200);
border-radius: 100px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
background: var(--card-bg);
color: var(--gray-600);
}
.assessment-option:hover {
border-color: var(--primary);
color: var(--primary);
}
.assessment-option.selected {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.assessment-option.selected-partial {
background: var(--warning-bg);
border-color: var(--warning);
color: #a16207;
}
.assessment-option.selected-no {
background: var(--danger-bg);
border-color: var(--danger);
color: var(--danger);
}
.assessment-score-preview {
margin-top: 16px;
padding: 16px;
background: var(--gray-50);
border-radius: var(--border-radius);
text-align: center;
}
.assessment-score-preview .score-val {
font-size: 2rem;
font-weight: 700;
}
.assessment-score-preview .score-label {
font-size: 0.78rem;
color: var(--gray-500);
}
/* Assessment history */
.assessment-history-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--gray-100);
font-size: 0.82rem;
}
.assessment-history-item:last-child { border-bottom: none; }
.assessment-history-date {
color: var(--gray-500);
min-width: 80px;
}
.assessment-history-score {
font-weight: 700;
}
.loading-overlay {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 20px;
flex-direction: column;
gap: 12px;
color: var(--gray-500);
}
.loading-overlay .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;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.text-link {
color: var(--primary);
cursor: pointer;
font-weight: 500;
}
.text-link:hover {
text-decoration: underline;
}
.content-header-actions {
display: flex;
gap: 8px;
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="supply_chain.title">Sicurezza Supply Chain</h2>
<div class="content-header-actions">
<button class="btn btn-secondary" onclick="openCategoriesModal()"><i class="fas fa-tags"></i> <span data-i18n="sp.categories_btn">Categorie</span></button>
<button class="btn btn-secondary" onclick="openTemplatesModal()"><i class="fas fa-clipboard-list"></i> <span data-i18n="sp.templates_btn">Template</span></button>
<button class="btn btn-secondary" onclick="openImportModal()"><i class="fas fa-file-import"></i> <span data-i18n="sp.import_btn">Importa</span></button>
<button class="btn btn-primary" onclick="openCreateModal()"><span data-i18n="sp.new_supplier">+ Nuovo Fornitore</span></button>
</div>
</header>
<div class="content-body" id="page-content">
<!-- Risk Overview -->
<div class="risk-overview" id="risk-overview">
<div class="risk-card">
<div class="risk-card-value" id="stat-total">--</div>
<div class="risk-card-label">Totale Fornitori</div>
</div>
<div class="risk-card">
<div class="risk-card-value danger" id="stat-critical">--</div>
<div class="risk-card-label">Critici Non Conformi</div>
</div>
<div class="risk-card">
<div class="risk-card-value warning" id="stat-expiring">--</div>
<div class="risk-card-label">Contratti in Scadenza</div>
</div>
<div class="risk-card">
<div class="risk-card-value success" id="stat-avg-score">--</div>
<div class="risk-card-label">Score Rischio Medio</div>
</div>
</div>
<!-- Criticality Chart -->
<div class="card mb-24">
<div class="card-header">
<h3>Distribuzione Criticita'</h3>
</div>
<div class="card-body">
<div class="criticality-chart" id="criticality-chart">
<div class="chart-bar-group">
<div class="chart-bar" id="chart-bar-critical" style="height:0;background:var(--danger);"><span class="chart-bar-value" id="chart-val-critical">0</span></div>
<span class="chart-bar-label">Critico</span>
</div>
<div class="chart-bar-group">
<div class="chart-bar" id="chart-bar-high" style="height:0;background:#f97316;"><span class="chart-bar-value" id="chart-val-high">0</span></div>
<span class="chart-bar-label">Alto</span>
</div>
<div class="chart-bar-group">
<div class="chart-bar" id="chart-bar-medium" style="height:0;background:var(--warning);"><span class="chart-bar-value" id="chart-val-medium">0</span></div>
<span class="chart-bar-label">Medio</span>
</div>
<div class="chart-bar-group">
<div class="chart-bar" id="chart-bar-low" style="height:0;background:var(--secondary);"><span class="chart-bar-value" id="chart-val-low">0</span></div>
<span class="chart-bar-label">Basso</span>
</div>
</div>
</div>
</div>
<!-- Filters + Table -->
<div id="suppliers-list-section">
<div class="filters-bar">
<select class="form-select" id="filter-criticality" onchange="applyFilters()">
<option value="">Tutte le Criticita'</option>
<option value="critical">Critico</option>
<option value="high">Alto</option>
<option value="medium">Medio</option>
<option value="low">Basso</option>
</select>
<select class="form-select" id="filter-status" onchange="applyFilters()">
<option value="">Tutti gli Stati</option>
<option value="active">Attivo</option>
<option value="under_review">In Revisione</option>
<option value="suspended">Sospeso</option>
<option value="terminated">Terminato</option>
</select>
</div>
<div class="card">
<div class="table-container" id="suppliers-table">
<div class="loading-overlay">
<div class="spinner"></div>
<span>Caricamento fornitori...</span>
</div>
</div>
</div>
</div>
<!-- Detail View (hidden by default) -->
<div id="supplier-detail-view" style="display:none;"></div>
</div>
</main>
</div>
<script src="/js/api.js"></script>
<script src="/js/common.js"></script>
<script src="/js/i18n.js"></script>
<script src="/js/help.js"></script>
<script>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
HelpSystem.init();
// ── Labels ──────────────────────────────────────────────
const CRITICALITY_LABELS = {
low: 'Basso',
medium: 'Medio',
high: 'Alto',
critical: 'Critico'
};
const CRITICALITY_BADGE_CLASS = {
low: 'badge-success',
medium: 'badge-warning',
high: 'badge-danger',
critical: 'badge-danger'
};
const STATUS_LABELS = {
active: 'Attivo',
under_review: 'In Revisione',
suspended: 'Sospeso',
terminated: 'Terminato'
};
const STATUS_BADGE_CLASS = {
active: 'badge-success',
under_review: 'badge-info',
suspended: 'badge-warning',
terminated: 'badge-neutral'
};
// ── Assessment Questions ────────────────────────────────
const ASSESSMENT_QUESTIONS = [
{ id: 'security_certs', text: 'Il fornitore ha certificazioni di sicurezza (ISO 27001, SOC2)?', weight: 2 },
{ id: 'security_sla', text: 'Esistono SLA definiti per la sicurezza?', weight: 1.5 },
{ id: 'incident_plan', text: 'Il fornitore ha un piano di incident response?', weight: 2 },
{ id: 'data_encryption', text: 'I dati sono crittografati in transito e a riposo?', weight: 2 },
{ id: 'periodic_audits', text: 'Il fornitore effettua audit periodici?', weight: 1.5 },
{ id: 'access_control', text: 'Il fornitore implementa controlli di accesso robusti?', weight: 1 },
{ id: 'data_backup', text: 'Il fornitore dispone di procedure di backup e disaster recovery?', weight: 1.5 },
{ id: 'compliance_gdpr', text: 'Il fornitore e\' conforme al GDPR?', weight: 1 },
{ id: 'security_training', text: 'Il personale del fornitore riceve formazione sulla sicurezza?', weight: 1 },
{ id: 'vulnerability_mgmt', text: 'Il fornitore gestisce attivamente le vulnerabilita\'?', weight: 1.5 }
];
// ── State ───────────────────────────────────────────────
let allSuppliers = [];
let currentView = 'list';
// ── Load Suppliers ──────────────────────────────────────
async function loadSuppliers() {
const container = document.getElementById('suppliers-table');
container.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento fornitori...</span></div>';
try {
const result = await api.listSuppliers();
if (result.success) {
allSuppliers = result.data || [];
updateRiskOverview(allSuppliers);
updateCriticalityChart(allSuppliers);
applyFilters();
} else {
container.innerHTML = '<div class="empty-state"><h4>Errore nel caricamento</h4><p>' + escapeHtml(result.message || '') + '</p></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4><p>Impossibile caricare i fornitori.</p></div>';
}
}
function applyFilters() {
const criticality = document.getElementById('filter-criticality').value;
const status = document.getElementById('filter-status').value;
let filtered = allSuppliers;
if (criticality) {
filtered = filtered.filter(s => s.criticality === criticality);
}
if (status) {
filtered = filtered.filter(s => s.status === status);
}
renderSuppliersTable(filtered);
}
function updateRiskOverview(suppliers) {
const total = suppliers.length;
const criticalNonCompliant = suppliers.filter(s =>
(s.criticality === 'critical' || s.criticality === 'high') && !s.security_requirements_met
).length;
const today = new Date();
const in90Days = new Date(today.getTime() + 90 * 86400000);
const expiring = suppliers.filter(s => {
if (!s.contract_expiry_date || s.status !== 'active') return false;
const exp = new Date(s.contract_expiry_date);
return exp <= in90Days && exp >= today;
}).length;
const scored = suppliers.filter(s => s.risk_score != null && s.risk_score !== '');
const avgScore = scored.length > 0
? Math.round(scored.reduce((sum, s) => sum + Number(s.risk_score), 0) / scored.length)
: 0;
document.getElementById('stat-total').textContent = total;
document.getElementById('stat-critical').textContent = criticalNonCompliant;
document.getElementById('stat-expiring').textContent = expiring;
document.getElementById('stat-avg-score').textContent = avgScore > 0 ? avgScore + '/100' : '-';
}
function updateCriticalityChart(suppliers) {
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
suppliers.forEach(s => {
if (counts.hasOwnProperty(s.criticality)) {
counts[s.criticality]++;
}
});
const maxCount = Math.max(...Object.values(counts), 1);
Object.entries(counts).forEach(([level, count]) => {
const bar = document.getElementById('chart-bar-' + level);
const val = document.getElementById('chart-val-' + level);
if (bar) {
const height = maxCount > 0 ? Math.max((count / maxCount) * 100, count > 0 ? 10 : 4) : 4;
bar.style.height = height + 'px';
}
if (val) val.textContent = count;
});
}
function renderSuppliersTable(suppliers) {
const container = document.getElementById('suppliers-table');
if (!suppliers || suppliers.length === 0) {
container.innerHTML = `
<div class="empty-state" style="padding:40px 20px;">
<svg viewBox="0 0 20 20" fill="currentColor" style="width:48px;height:48px;color:var(--gray-300);">
<path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"/>
</svg>
<h4>Nessun fornitore trovato</h4>
<p>Aggiungi il tuo primo fornitore per iniziare il monitoraggio della supply chain.</p>
</div>`;
return;
}
let html = `
<table>
<thead>
<tr>
<th>Nome</th>
<th>Tipo Servizio</th>
<th>Criticita'</th>
<th>Score Rischio</th>
<th>Stato</th>
<th>Ultima Valutazione</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>`;
suppliers.forEach(s => {
const critLabel = CRITICALITY_LABELS[s.criticality] || s.criticality || '-';
const critBadge = CRITICALITY_BADGE_CLASS[s.criticality] || 'badge-neutral';
const statusLabel = STATUS_LABELS[s.status] || s.status || 'Attivo';
const statusBadge = STATUS_BADGE_CLASS[s.status] || 'badge-neutral';
const riskScore = s.risk_score != null && s.risk_score !== '' ? Number(s.risk_score) : null;
const scoreColor = riskScore !== null ? getScoreColor(riskScore) : 'var(--gray-300)';
html += `
<tr>
<td>
<span class="text-link" onclick="viewSupplier(${s.id})">${escapeHtml(s.name)}</span>
</td>
<td>${escapeHtml(s.service_type || '-')}</td>
<td>
<span class="badge ${critBadge}">${escapeHtml(critLabel)}</span>
</td>
<td>
<div class="risk-score-cell">
<div class="risk-score-bar">
<div class="risk-score-fill" style="width:${riskScore !== null ? riskScore : 0}%;background:${scoreColor};"></div>
</div>
<span class="risk-score-value" style="color:${scoreColor};">${riskScore !== null ? riskScore : '-'}</span>
</div>
</td>
<td><span class="badge ${statusBadge}">${escapeHtml(statusLabel)}</span></td>
<td>${formatDate(s.last_assessment_date)}</td>
<td>
<div class="supplier-actions">
<button class="btn btn-sm btn-ghost" onclick="viewSupplier(${s.id})" title="Visualizza">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
</button>
<button class="btn btn-sm btn-ghost" onclick="openEditModal(${s.id})" title="Modifica">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><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 btn-sm btn-primary" onclick="openAssessmentModal(${s.id})" title="Valuta (interna)">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
</button>
<button class="btn btn-sm btn-ghost" onclick="sendSupplierQuestionnaire(${s.id}, '${escapeHtml((s.name||'').replace(/'/g,"\\'"))}')" title="Invia questionario al fornitore">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/></svg>
</button>
</div>
</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
// ── View Supplier Detail ────────────────────────────────
async function viewSupplier(id) {
// Hide overview and list
document.getElementById('risk-overview').style.display = 'none';
document.querySelector('.card.mb-24').style.display = 'none';
document.getElementById('suppliers-list-section').style.display = 'none';
const detailView = document.getElementById('supplier-detail-view');
detailView.style.display = 'block';
detailView.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento dettagli...</span></div>';
currentView = 'detail';
try {
const result = await api.getSupplier(id);
if (result.success && result.data) {
renderSupplierDetail(result.data);
} else {
detailView.innerHTML = '<div class="empty-state"><h4>Fornitore non trovato</h4></div>';
}
} catch (e) {
detailView.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
}
}
function renderSupplierDetail(supplier) {
const detailView = document.getElementById('supplier-detail-view');
const critLabel = CRITICALITY_LABELS[supplier.criticality] || supplier.criticality || '-';
const critBadge = CRITICALITY_BADGE_CLASS[supplier.criticality] || 'badge-neutral';
const statusLabel = STATUS_LABELS[supplier.status] || supplier.status || 'Attivo';
const statusBadge = STATUS_BADGE_CLASS[supplier.status] || 'badge-neutral';
const riskScore = supplier.risk_score != null && supplier.risk_score !== '' ? Number(supplier.risk_score) : null;
const scoreColor = riskScore !== null ? getScoreColor(riskScore) : 'var(--gray-300)';
// Score ring SVG
const ringSize = 120;
const ringRadius = (ringSize / 2) - 10;
const ringCircumference = 2 * Math.PI * ringRadius;
const ringOffset = riskScore !== null
? ringCircumference - (riskScore / 100) * ringCircumference
: ringCircumference;
detailView.innerHTML = `
<div class="detail-header">
<div>
<div style="margin-bottom:8px;">
<span class="text-link" onclick="backToList()" style="font-size:0.82rem;">
&larr; Torna all'elenco
</span>
</div>
<h3>${escapeHtml(supplier.name)}</h3>
<div style="margin-top:8px;display:flex;gap:8px;">
<span class="badge ${critBadge}">${escapeHtml(critLabel)}</span>
<span class="badge ${statusBadge}">${escapeHtml(statusLabel)}</span>
${supplier.security_requirements_met ? '<span class="badge badge-success">Conforme</span>' : '<span class="badge badge-danger">Non Conforme</span>'}
</div>
</div>
<div class="detail-header-actions">
<button class="btn btn-primary" onclick="openAssessmentModal(${supplier.id})">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
Valuta Fornitore
</button>
<button class="btn btn-secondary" onclick="sendSupplierQuestionnaire(${supplier.id}, '${escapeHtml((supplier.name||'').replace(/'/g,"\\'"))}')">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/></svg>
Invia questionario
</button>
<button class="btn btn-secondary" onclick="openEditModal(${supplier.id})">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><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>
Modifica
</button>
</div>
</div>
<div class="detail-grid">
<div>
<div class="card" style="margin-bottom:16px;">
<div class="card-header">
<h3>Informazioni Fornitore</h3>
</div>
<div class="card-body" style="padding:0;">
<div class="info-grid">
<div class="info-item">
<div class="info-item-label">Nome</div>
<div class="info-item-value">${escapeHtml(supplier.name)}</div>
</div>
<div class="info-item">
<div class="info-item-label">Partita IVA</div>
<div class="info-item-value">${escapeHtml(supplier.vat_number || '-')}</div>
</div>
<div class="info-item">
<div class="info-item-label">Referente</div>
<div class="info-item-value">${escapeHtml(supplier.contact_name || '-')}</div>
</div>
<div class="info-item">
<div class="info-item-label">Email</div>
<div class="info-item-value">${supplier.contact_email ? '<a href="mailto:' + escapeHtml(supplier.contact_email) + '">' + escapeHtml(supplier.contact_email) + '</a>' : '-'}</div>
</div>
<div class="info-item">
<div class="info-item-label">Tipo Servizio</div>
<div class="info-item-value">${escapeHtml(supplier.service_type || '-')}</div>
</div>
<div class="info-item">
<div class="info-item-label">Criticita'</div>
<div class="info-item-value"><span class="badge ${critBadge}">${escapeHtml(critLabel)}</span></div>
</div>
<div class="info-item">
<div class="info-item-label">Inizio Contratto</div>
<div class="info-item-value">${formatDate(supplier.contract_start_date)}</div>
</div>
<div class="info-item">
<div class="info-item-label">Scadenza Contratto</div>
<div class="info-item-value">${formatDate(supplier.contract_expiry_date)}</div>
</div>
</div>
</div>
</div>
${supplier.service_description ? `
<div class="card" style="margin-bottom:16px;">
<div class="card-header"><h3>Descrizione Servizio</h3></div>
<div class="card-body">
<p style="font-size:0.875rem;color:var(--gray-700);white-space:pre-wrap;">${escapeHtml(supplier.service_description)}</p>
</div>
</div>` : ''}
${supplier.notes ? `
<div class="card">
<div class="card-header"><h3>Note</h3></div>
<div class="card-body">
<p style="font-size:0.875rem;color:var(--gray-700);white-space:pre-wrap;">${escapeHtml(supplier.notes)}</p>
</div>
</div>` : ''}
</div>
<div>
<!-- Risk Score Gauge -->
<div class="meta-card" style="text-align:center;">
<h4 style="text-align:left;">Score Rischio</h4>
<div class="score-ring">
<svg width="${ringSize}" height="${ringSize}" viewBox="0 0 ${ringSize} ${ringSize}">
<circle class="score-ring-bg" cx="${ringSize/2}" cy="${ringSize/2}" r="${ringRadius}"/>
<circle class="score-ring-fill" cx="${ringSize/2}" cy="${ringSize/2}" r="${ringRadius}"
stroke="${scoreColor}"
stroke-dasharray="${ringCircumference}"
stroke-dashoffset="${ringOffset}"/>
</svg>
<div class="score-ring-value">
<div class="score-ring-number" style="color:${scoreColor};">${riskScore !== null ? riskScore : '-'}</div>
<div class="score-ring-label">su 100</div>
</div>
</div>
<p style="font-size:0.78rem;color:var(--gray-500);margin-top:8px;">
${riskScore !== null
? (riskScore >= 70 ? 'Requisiti di sicurezza soddisfatti' : 'Requisiti di sicurezza non soddisfatti')
: 'Nessuna valutazione effettuata'}
</p>
</div>
<!-- Assessment Info -->
<div class="meta-card">
<h4>Valutazione</h4>
<div class="meta-row">
<span class="meta-label">Ultima Valutazione</span>
<span class="meta-value">${formatDate(supplier.last_assessment_date)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Prossima Valutazione</span>
<span class="meta-value">${formatDate(supplier.next_assessment_date)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Requisiti Sicurezza</span>
<span class="meta-value">
${supplier.security_requirements_met
? '<span class="badge badge-success">Soddisfatti</span>'
: '<span class="badge badge-danger">Non Soddisfatti</span>'}
</span>
</div>
<div class="meta-row">
<span class="meta-label">Creato il</span>
<span class="meta-value">${formatDate(supplier.created_at)}</span>
</div>
</div>
</div>
</div>
`;
}
function backToList() {
document.getElementById('supplier-detail-view').style.display = 'none';
document.getElementById('risk-overview').style.display = '';
document.querySelector('.card.mb-24').style.display = '';
document.getElementById('suppliers-list-section').style.display = '';
currentView = 'list';
}
// ── Create / Edit Modal ─────────────────────────────────
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);
}
async function openEditModal(id) {
try {
await ensureCategories();
const result = await api.getSupplier(id);
if (result.success && result.data) {
showSupplierFormModal(result.data);
} else {
showNotification('Impossibile caricare il fornitore.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
function showSupplierFormModal(supplier) {
const isEdit = !!supplier;
const title = isEdit ? 'Modifica Fornitore' : 'Nuovo Fornitore';
const criticalityOptions = Object.entries(CRITICALITY_LABELS).map(([val, label]) =>
`<option value="${val}" ${supplier && supplier.criticality === val ? 'selected' : ''}>${label}</option>`
).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 = `
<form id="supplier-form" onsubmit="return false;">
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="form-group">
<label class="form-label">Nome Fornitore <span class="required">*</span></label>
<input type="text" class="form-input" id="form-name" value="${isEdit ? escapeHtml(supplier.name) : ''}" required placeholder="Nome dell'azienda">
</div>
<div class="form-group">
<label class="form-label">Partita IVA</label>
<input type="text" class="form-input" id="form-vat" value="${isEdit && supplier.vat_number ? escapeHtml(supplier.vat_number) : ''}" placeholder="IT01234567890">
</div>
</div>
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="form-group">
<label class="form-label">Referente</label>
<input type="text" class="form-input" id="form-contact-name" value="${isEdit && supplier.contact_name ? escapeHtml(supplier.contact_name) : ''}" placeholder="Nome e cognome">
</div>
<div class="form-group">
<label class="form-label">Email Contatto</label>
<input type="email" class="form-input" id="form-contact-email" value="${isEdit && supplier.contact_email ? escapeHtml(supplier.contact_email) : ''}" placeholder="email@azienda.it">
</div>
</div>
<div class="form-row" style="display:grid;grid-template-columns:2fr 1fr;gap:16px;">
<div class="form-group">
<label class="form-label">Tipo Servizio <span class="required">*</span></label>
<input type="text" class="form-input" id="form-service-type" value="${isEdit && supplier.service_type ? escapeHtml(supplier.service_type) : ''}" required placeholder="es. Cloud Hosting, Sviluppo SW, Consulenza">
</div>
<div class="form-group">
<label class="form-label">Criticita'</label>
<select class="form-select" id="form-criticality">
${criticalityOptions}
</select>
</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">
<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>
</div>
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="form-group">
<label class="form-label">Inizio Contratto</label>
<input type="date" class="form-input" id="form-contract-start" value="${isEdit && supplier.contract_start_date ? supplier.contract_start_date.substring(0, 10) : ''}">
</div>
<div class="form-group">
<label class="form-label">Scadenza Contratto</label>
<input type="date" class="form-input" id="form-contract-expiry" value="${isEdit && supplier.contract_expiry_date ? supplier.contract_expiry_date.substring(0, 10) : ''}">
</div>
</div>
<div class="form-group">
<label class="form-label">Note</label>
<textarea class="form-textarea" id="form-notes" rows="3" placeholder="Note aggiuntive...">${isEdit && supplier.notes ? escapeHtml(supplier.notes) : ''}</textarea>
</div>
</form>
`;
showModal(title, content, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="saveSupplier(${isEdit ? supplier.id : 'null'})">${isEdit ? 'Salva Modifiche' : 'Aggiungi Fornitore'}</button>
`
});
}
async function saveSupplier(id) {
const name = document.getElementById('form-name').value.trim();
const serviceType = document.getElementById('form-service-type').value.trim();
if (!name) {
showNotification('Il nome del fornitore e\' obbligatorio.', 'warning');
return;
}
if (!serviceType) {
showNotification('Il tipo di servizio e\' obbligatorio.', 'warning');
return;
}
const data = {
name: name,
vat_number: document.getElementById('form-vat').value.trim() || null,
contact_email: document.getElementById('form-contact-email').value.trim() || null,
contact_name: document.getElementById('form-contact-name').value.trim() || null,
service_type: serviceType,
service_description: document.getElementById('form-service-desc').value.trim() || null,
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_expiry_date: document.getElementById('form-contract-expiry').value || null,
notes: document.getElementById('form-notes').value.trim() || null,
};
try {
let result;
if (id) {
result = await api.updateSupplier(id, data);
} else {
result = await api.createSupplier(data);
}
if (result.success) {
closeModal();
showNotification(id ? 'Fornitore aggiornato con successo.' : 'Fornitore aggiunto con successo.', 'success');
loadSuppliers();
if (currentView === 'detail' && id) {
viewSupplier(id);
}
} else {
showNotification(result.message || 'Errore nel salvataggio.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Assessment Modal ────────────────────────────────────
let assessmentAnswers = {};
function openAssessmentModal(supplierId) {
assessmentAnswers = {};
let questionsHtml = '';
ASSESSMENT_QUESTIONS.forEach((q, idx) => {
questionsHtml += `
<div class="assessment-question">
<div class="assessment-question-text">${idx + 1}. ${escapeHtml(q.text)}</div>
<div class="assessment-options">
<span class="assessment-option" data-q="${q.id}" data-val="yes" onclick="selectAnswer('${q.id}', 'yes', this)">Si'</span>
<span class="assessment-option" data-q="${q.id}" data-val="partial" onclick="selectAnswer('${q.id}', 'partial', this)">Parziale</span>
<span class="assessment-option" data-q="${q.id}" data-val="no" onclick="selectAnswer('${q.id}', 'no', this)">No</span>
</div>
</div>
`;
});
const content = `
<p style="margin-bottom:16px;font-size:0.9rem;color:var(--gray-600);">
Rispondi alle domande per valutare il livello di sicurezza del fornitore.
Il punteggio di rischio verra' calcolato automaticamente.
</p>
<div id="assessment-questions">
${questionsHtml}
</div>
<div class="assessment-score-preview" id="assessment-score-preview">
<div class="score-label">Score Rischio Calcolato</div>
<div class="score-val" id="assessment-live-score" style="color:var(--gray-300);">--</div>
</div>
`;
showModal('Valutazione Fornitore', content, {
size: 'lg',
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="submitAssessment(${supplierId})">Salva Valutazione</button>
`
});
}
function selectAnswer(questionId, value, el) {
// Remove selected from siblings
const parent = el.parentElement;
parent.querySelectorAll('.assessment-option').forEach(opt => {
opt.classList.remove('selected', 'selected-partial', 'selected-no');
});
// Add selected class
if (value === 'yes') {
el.classList.add('selected');
} else if (value === 'partial') {
el.classList.add('selected-partial');
} else {
el.classList.add('selected-no');
}
// Store answer
const question = ASSESSMENT_QUESTIONS.find(q => q.id === questionId);
assessmentAnswers[questionId] = {
id: questionId,
value: value,
weight: question ? question.weight : 1
};
// Update live score
updateLiveScore();
}
function updateLiveScore() {
const answers = Object.values(assessmentAnswers);
if (answers.length === 0) {
document.getElementById('assessment-live-score').textContent = '--';
document.getElementById('assessment-live-score').style.color = 'var(--gray-300)';
return;
}
let totalScore = 0;
let totalWeight = 0;
answers.forEach(a => {
const weight = a.weight || 1;
let val = 0;
if (a.value === 'yes') val = 100;
else if (a.value === 'partial') val = 50;
totalScore += val * weight;
totalWeight += 100 * weight;
});
const score = totalWeight > 0 ? Math.round(totalScore / totalWeight * 100) : 0;
const scoreEl = document.getElementById('assessment-live-score');
scoreEl.textContent = score + '/100';
scoreEl.style.color = getScoreColor(score);
}
async function submitAssessment(supplierId) {
const answers = Object.values(assessmentAnswers);
if (answers.length === 0) {
showNotification('Rispondi ad almeno una domanda prima di salvare.', 'warning');
return;
}
try {
const result = await api.assessSupplier(supplierId, {
assessment_responses: answers
});
if (result.success) {
closeModal();
const score = result.data?.risk_score;
showNotification(
'Valutazione completata. Score: ' + (score != null ? score + '/100' : 'N/D'),
'success'
);
loadSuppliers();
if (currentView === 'detail') {
viewSupplier(supplierId);
}
} else {
showNotification(result.message || 'Errore nel salvataggio della valutazione.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Invio questionario self-assessment al fornitore (link esterno) ──
async function sendSupplierQuestionnaire(supplierId, supplierName) {
const def = '';
const email = prompt('Invia il questionario di sicurezza a "' + (supplierName || 'fornitore') +
'".\nEmail del fornitore (lascia vuoto per usare il contatto in anagrafica):', def);
if (email === null) return; // annullato
try {
const result = await api.sendSupplierQuestionnaire(supplierId, email.trim() || null);
if (result.success) {
const link = result.data?.link;
showNotification('Questionario inviato' + (result.data?.sent_to ? ' a ' + result.data.sent_to : '') + '.', 'success');
if (link) {
// mostra anche il link generato (utile se l'email non parte / per invio manuale)
prompt('Link del questionario (valido 30 giorni) — puoi inoltrarlo manualmente:', link);
}
} else {
showNotification(result.message || 'Errore nell\'invio del questionario.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Import fornitori (CSV / incolla) → upsert per external_ref ──
function openImportModal() {
const ex = document.getElementById('importModalDyn');
if (ex) ex.remove();
const ov = document.createElement('div');
ov.id = 'importModalDyn';
ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;padding:16px;';
ov.innerHTML = `
<div style="background:#fff;border-radius:10px;max-width:640px;width:100%;max-height:90vh;overflow:auto;">
<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid #e2e8f0;">
<h3 style="margin:0;"><i class="fas fa-file-import"></i> Importa fornitori</h3>
<button onclick="document.getElementById('importModalDyn').remove()" style="background:none;border:none;font-size:22px;cursor:pointer;line-height:1;">&times;</button>
</div>
<div style="padding:20px;">
<p style="margin:0 0 12px;color:#64748b;font-size:14px;">
Incolla un CSV o caricane uno. Prima riga = intestazioni. Colonne: <code>name</code> (obbligatoria),
<code>vat_number</code>, <code>contact_email</code>, <code>contact_name</code>,
<code>service_type</code>, <code>criticality</code> (low/medium/high/critical),
<code>category_slug</code>, <code>external_ref</code>. I record con stesso <code>external_ref</code> vengono aggiornati.
</p>
<input type="file" id="importFile" accept=".csv,text/csv" class="form-input" style="margin-bottom:10px;">
<textarea id="importCsv" class="form-input" rows="8" style="width:100%;font-family:monospace;font-size:13px;"
placeholder="name,vat_number,contact_email,criticality,category_slug&#10;ACME Srl,IT01234567890,security@acme.it,high,cloud_provider"></textarea>
<div id="importResult" style="margin-top:10px;font-size:14px;"></div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;padding:16px 20px;border-top:1px solid #e2e8f0;">
<button class="btn btn-secondary" onclick="document.getElementById('importModalDyn').remove()">Annulla</button>
<button class="btn btn-primary" id="importRunBtn" onclick="runSupplierImport()"><i class="fas fa-upload"></i> Importa</button>
</div>
</div>`;
document.body.appendChild(ov);
document.getElementById('importFile').addEventListener('change', (e) => {
const f = e.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => { document.getElementById('importCsv').value = r.result; };
r.readAsText(f);
});
}
function parseCsv(text) {
const rows = [];
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter(l => l.trim() !== '');
if (lines.length < 2) return [];
const split = (line) => {
const out = []; let cur = ''; let inQ = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (inQ) {
if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; }
else if (c === '"') inQ = false;
else cur += c;
} else {
if (c === '"') inQ = true;
else if (c === ',' || c === ';') { out.push(cur); cur = ''; }
else cur += c;
}
}
out.push(cur);
return out.map(s => s.trim());
};
const headers = split(lines[0]).map(h => h.toLowerCase());
for (let i = 1; i < lines.length; i++) {
const cells = split(lines[i]);
const obj = {};
headers.forEach((h, idx) => { if (h) obj[h] = cells[idx] ?? ''; });
if ((obj.name || '').trim() !== '') rows.push(obj);
}
return rows;
}
async function runSupplierImport() {
const txt = document.getElementById('importCsv').value || '';
const resEl = document.getElementById('importResult');
const btn = document.getElementById('importRunBtn');
const rows = parseCsv(txt);
if (rows.length === 0) {
resEl.innerHTML = '<span style="color:#dc2626;">Nessuna riga valida (serve intestazione + almeno un fornitore con "name").</span>';
return;
}
if (rows.length > 1000) {
resEl.innerHTML = '<span style="color:#dc2626;">Troppi record (max 1000).</span>';
return;
}
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Importo...';
try {
const res = await api.importSuppliers(rows);
if (res.success) {
const d = res.data || {};
let html = `<span style="color:#16a34a;">Completato: creati ${d.created||0}, aggiornati ${d.updated||0}, saltati ${d.skipped||0}.</span>`;
if (Array.isArray(d.errors) && d.errors.length) {
const items = d.errors.slice(0, 20).map(e => `<li>Riga ${(e.row != null ? e.row + 1 : '?')}: ${escapeHtml(String(e.error || ''))}</li>`).join('');
html += `<details style="margin-top:8px;"><summary style="cursor:pointer;color:#b45309;">${d.errors.length} riga/e scartata/e — dettagli</summary><ul style="margin:6px 0 0 18px;color:#b45309;font-size:13px;">${items}</ul></details>`;
}
resEl.innerHTML = html;
showNotification(`Import: ${d.created||0} creati, ${d.updated||0} aggiornati`, 'success');
loadSuppliers();
const keepOpen = Array.isArray(d.errors) && d.errors.length > 0;
if (!keepOpen) setTimeout(() => { const m = document.getElementById('importModalDyn'); if (m) m.remove(); }, 1800);
} else {
resEl.innerHTML = `<span style="color:#dc2626;">${res.message || 'Errore import'}</span>`;
}
} catch (e) {
resEl.innerHTML = `<span style="color:#dc2626;">Errore: ${(e && e.message) || e}</span>`;
} finally {
btn.disabled = false; btn.innerHTML = '<i class="fas fa-upload"></i> Importa';
}
}
// ════════════════════════════════════════════════════════
// 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()">&larr; 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 ────────────────────────────────────────────────
loadSuppliers();
</script>
</body>
</html>