[FEAT] Visura auto-fill, adesione volontaria, modulo NCR/CAPA

1. Fix auto-fill visura: mapping corretto suggested_sector e employees_range,
   indicatori visivi verdi sui campi auto-compilati, fatturato sempre manuale
2. Adesione volontaria: colonna voluntary_compliance, checkbox in onboarding
   step 5 quando not_applicable, toggle in settings, reset su ri-classificazione
3. Modulo NCR/CAPA: NonConformityController con 10 endpoint API,
   tabelle non_conformities + capa_actions, generazione NCR dai gap assessment,
   predisposizione integrazione SistemiG.agile (webhook + sync)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 08:12:57 +01:00
co-authored by Claude Opus 4.6
parent 517cab76a5
commit 4e3408e9f6
11 changed files with 985 additions and 21 deletions
+67
View File
@@ -99,6 +99,25 @@
</div>
<div class="card-body" id="ai-analysis-content"></div>
</div>
<!-- Generate NCR from gaps -->
<div class="card mb-24" id="ncr-generation-card">
<div class="card-header">
<h3>Non Conformita'</h3>
</div>
<div class="card-body" style="text-align:center; padding:32px;">
<p style="color:var(--gray-600); margin-bottom:16px; font-size:0.875rem;">
Genera automaticamente le non conformita' (NCR) dai gap identificati nell'assessment.
I controlli con stato "Non Implementato" saranno classificati come <strong>major</strong>,
quelli "Parzialmente Implementato" come <strong>minor</strong>.
</p>
<button class="btn btn-primary" id="btn-generate-ncr" onclick="generateNCRs()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/></svg>
Genera Non Conformita' dai Gap
</button>
<div id="ncr-generation-result" style="display:none; margin-top:16px;"></div>
</div>
</div>
</div>
</div>
</main>
@@ -590,6 +609,54 @@
showNotification('Errore di connessione.', 'error');
}
}
// ── Generate NCRs from Assessment Gaps ─────────────────────
async function generateNCRs() {
if (!currentAssessmentId) {
showNotification('Nessun assessment selezionato.', 'warning');
return;
}
const btn = document.getElementById('btn-generate-ncr');
btn.disabled = true;
btn.innerHTML = '<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0;"></div> Generazione in corso...';
try {
const result = await api.createNCRsFromAssessment(currentAssessmentId);
const resultDiv = document.getElementById('ncr-generation-result');
resultDiv.style.display = 'block';
if (result.success && result.data) {
const total = result.data.total || 0;
if (total > 0) {
const majorCount = (result.data.created || []).filter(n => n.severity === 'major').length;
const minorCount = total - majorCount;
resultDiv.innerHTML = `
<div style="background:var(--secondary-bg); border:1px solid #bbf7d0; border-radius:var(--border-radius); padding:16px; text-align:left;">
<strong style="color:#15803d;">${total} non conformita' generate con successo</strong>
<div style="margin-top:8px; font-size:0.8125rem; color:var(--gray-600);">
${majorCount > 0 ? `<span style="color:var(--danger); font-weight:600;">${majorCount} major</span> ` : ''}
${minorCount > 0 ? `<span style="color:var(--warning); font-weight:600;">${minorCount} minor</span>` : ''}
</div>
</div>`;
showNotification(`${total} non conformita' generate.`, 'success');
} else {
resultDiv.innerHTML = '<p style="color:var(--gray-500);">Nessun gap trovato — tutti i controlli sono implementati.</p>';
showNotification('Nessun gap trovato.', 'info');
}
btn.style.display = 'none';
} else {
resultDiv.innerHTML = `<p style="color:var(--danger);">${escapeHtml(result.message || 'Errore nella generazione.')}</p>`;
showNotification(result.message || 'Errore.', 'error');
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/></svg> Genera Non Conformita\' dai Gap';
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/></svg> Genera Non Conformita\' dai Gap';
}
}
</script>
</body>
</html>
+15
View File
@@ -98,6 +98,7 @@ $controllerMap = [
'audit' => 'AuditController',
'admin' => 'AdminController',
'onboarding' => 'OnboardingController',
'ncr' => 'NonConformityController',
];
if (!isset($controllerMap[$controllerName])) {
@@ -275,6 +276,20 @@ $actionMap = [
'POST:fetchCompany' => 'fetchCompany',
'POST:complete' => 'complete',
],
// ── NonConformityController (NCR & CAPA) ───────
'ncr' => [
'POST:create' => 'create',
'GET:list' => 'list',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'POST:{id}/capa' => 'addCapa',
'PUT:capa/{subId}' => 'updateCapa',
'POST:fromAssessment' => 'fromAssessment',
'GET:stats' => 'stats',
'POST:{id}/sync' => 'syncExternal',
'POST:webhook' => 'webhook',
],
];
// ═══════════════════════════════════════════════════════════════════════════
+13
View File
@@ -264,6 +264,19 @@ class NIS2API {
fetchCompany(vatNumber) { return this.post('/onboarding/fetch-company', { vat_number: vatNumber }); }
completeOnboarding(data) { return this.post('/onboarding/complete', data); }
// ═══════════════════════════════════════════════════════════════════
// Non-Conformity & CAPA
// ═══════════════════════════════════════════════════════════════════
listNCRs(params = {}) { return this.get('/ncr/list?' + new URLSearchParams(params)); }
createNCR(data) { return this.post('/ncr/create', data); }
getNCR(id) { return this.get(`/ncr/${id}`); }
updateNCR(id, data) { return this.put(`/ncr/${id}`, data); }
addCapa(ncrId, data) { return this.post(`/ncr/${ncrId}/capa`, data); }
updateCapa(capaId, data) { return this.put(`/ncr/capa/${capaId}`, data); }
createNCRsFromAssessment(assessmentId) { return this.post('/ncr/from-assessment', { assessment_id: assessmentId }); }
getNCRStats() { return this.get('/ncr/stats'); }
}
// Singleton globale
+211 -7
View File
@@ -598,6 +598,91 @@
gap: 12px;
}
/* ── Auto-fill indicators ──────────────────────────────────── */
.auto-filled .form-input,
.auto-filled .form-select {
border-color: #86efac;
background: #f0fdf4;
}
.auto-fill-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
margin-left: 8px;
font-size: 0.65rem;
font-weight: 600;
color: #15803d;
background: #dcfce7;
border-radius: 100px;
text-transform: uppercase;
letter-spacing: 0.03em;
vertical-align: middle;
}
/* ── Voluntary Compliance ──────────────────────────────────── */
.voluntary-section {
display: none;
margin-bottom: 24px;
}
.voluntary-section.visible {
display: block;
}
.voluntary-card {
border: 2px solid var(--primary-light);
border-radius: var(--border-radius-lg);
padding: 24px;
display: flex;
align-items: flex-start;
gap: 16px;
background: var(--card-bg);
transition: all var(--transition);
}
.voluntary-card.checked {
border-color: var(--primary);
background: var(--primary-bg);
}
.voluntary-card input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--primary);
flex-shrink: 0;
margin-top: 2px;
}
.voluntary-card h4 {
font-size: 0.9375rem;
font-weight: 700;
color: var(--gray-900);
margin-bottom: 6px;
}
.voluntary-card p {
font-size: 0.8125rem;
color: var(--gray-600);
line-height: 1.6;
margin: 0;
}
.classification-result.voluntary {
border-color: var(--primary);
background: linear-gradient(135deg, #eff6ff, #dbeafe);
}
.classification-result.voluntary .classification-result-icon {
background: var(--primary);
color: #fff;
}
.classification-result.voluntary .classification-result-label {
color: var(--primary);
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.wizard-header {
@@ -1040,6 +1125,22 @@
<div class="classification-result-desc" id="classification-desc"></div>
</div>
<!-- Voluntary Compliance Option -->
<div class="voluntary-section" id="voluntary-section">
<div class="voluntary-card" id="voluntary-card">
<input type="checkbox" id="voluntary-checkbox" onchange="onVoluntaryChange(this.checked)">
<div>
<h4>Adesione Volontaria alla NIS2</h4>
<p>
Anche se la tua organizzazione non rientra nell'ambito di applicazione obbligatorio della
Direttiva NIS2, puoi scegliere di aderire volontariamente adottando le misure di sicurezza
previste dalla normativa. Questo consente di migliorare la postura di cybersicurezza e di
prepararsi a possibili future designazioni.
</p>
</div>
</div>
</div>
<!-- Summary -->
<div class="summary-card">
<div class="summary-card-header">Riepilogo Dati Aziendali</div>
@@ -1142,9 +1243,22 @@
role: '',
phone: ''
},
classification: null
classification: null,
autoFilledFields: new Set(),
voluntaryCompliance: false
};
// ── Helper: parse employees range string to number ────────
function parseEmployeesRange(range) {
if (!range) return '';
if (typeof range === 'number') return range;
const str = String(range).trim();
if (str.endsWith('+')) return parseInt(str);
const parts = str.split('-');
if (parts.length === 2) return parseInt(parts[0]);
return parseInt(str) || '';
}
// ── Step 1: Method selection ────────────────────────────────────
function selectMethod(method) {
wizardState.method = method;
@@ -1308,11 +1422,24 @@
wizardState.company.website = d.website || d.sito_web || '';
wizardState.company.email = d.email || d.pec || '';
wizardState.company.phone = d.phone || d.telefono || '';
wizardState.company.sector = d.sector || '';
wizardState.company.employee_count = d.employee_count || d.employees || '';
wizardState.company.annual_turnover = d.annual_turnover || d.turnover || '';
wizardState.company.sector = d.suggested_sector || d.sector || '';
wizardState.company.employee_count = parseEmployeesRange(d.employees_range) || d.employee_count || d.employees || '';
// Fatturato resta SEMPRE manuale — non auto-compilare dalla visura
wizardState.company.annual_turnover = '';
wizardState.dataSource = 'Dati precompilati dalla visura camerale';
// Track auto-filled fields
wizardState.autoFilledFields.clear();
if (wizardState.company.name) wizardState.autoFilledFields.add('company-name');
if (wizardState.company.vat_number) wizardState.autoFilledFields.add('vat-number');
if (wizardState.company.fiscal_code) wizardState.autoFilledFields.add('fiscal-code');
if (wizardState.company.address) wizardState.autoFilledFields.add('address');
if (wizardState.company.city) wizardState.autoFilledFields.add('city');
if (wizardState.company.email) wizardState.autoFilledFields.add('company-email');
if (wizardState.company.phone) wizardState.autoFilledFields.add('company-phone');
if (wizardState.company.sector) wizardState.autoFilledFields.add('sector');
if (wizardState.company.employee_count) wizardState.autoFilledFields.add('employee-count');
showNotification('Visura analizzata con successo!', 'success');
// Auto-advance to step 3
@@ -1361,11 +1488,24 @@
wizardState.company.website = d.website || d.sito_web || '';
wizardState.company.email = d.email || d.pec || '';
wizardState.company.phone = d.phone || d.telefono || '';
wizardState.company.sector = d.sector || '';
wizardState.company.employee_count = d.employee_count || d.employees || '';
wizardState.company.annual_turnover = d.annual_turnover || d.turnover || '';
wizardState.company.sector = d.suggested_sector || d.sector || '';
wizardState.company.employee_count = parseEmployeesRange(d.employees_range) || d.employee_count || d.employees || '';
// Fatturato resta SEMPRE manuale
wizardState.company.annual_turnover = '';
wizardState.dataSource = 'Dati recuperati da CertiSource';
// Track auto-filled fields
wizardState.autoFilledFields.clear();
if (wizardState.company.name) wizardState.autoFilledFields.add('company-name');
if (wizardState.company.vat_number) wizardState.autoFilledFields.add('vat-number');
if (wizardState.company.fiscal_code) wizardState.autoFilledFields.add('fiscal-code');
if (wizardState.company.address) wizardState.autoFilledFields.add('address');
if (wizardState.company.city) wizardState.autoFilledFields.add('city');
if (wizardState.company.email) wizardState.autoFilledFields.add('company-email');
if (wizardState.company.phone) wizardState.autoFilledFields.add('company-phone');
if (wizardState.company.sector) wizardState.autoFilledFields.add('sector');
if (wizardState.company.employee_count) wizardState.autoFilledFields.add('employee-count');
showNotification('Dati aziendali recuperati con successo!', 'success');
// Auto-advance to step 3
@@ -1413,6 +1553,27 @@
} else {
banner.classList.remove('visible');
}
// Apply auto-fill visual indicators
const autoFields = wizardState.autoFilledFields;
document.querySelectorAll('#step-3 .form-input, #step-3 .form-select').forEach(el => {
const wrapper = el.closest('.form-group');
if (!wrapper) return;
// Remove previous indicators
wrapper.classList.remove('auto-filled');
const existing = wrapper.querySelector('.auto-fill-badge');
if (existing) existing.remove();
if (autoFields.has(el.id)) {
wrapper.classList.add('auto-filled');
const badge = document.createElement('span');
badge.className = 'auto-fill-badge';
badge.textContent = 'da visura';
const label = wrapper.querySelector('.form-label');
if (label) label.appendChild(badge);
}
});
}
function submitStep3() {
@@ -1553,6 +1714,21 @@
const result = classifyLocally(sector, employees, turnover);
wizardState.classification = result;
// Reset voluntary state when re-entering step 5
wizardState.voluntaryCompliance = false;
const voluntaryCheckbox = document.getElementById('voluntary-checkbox');
if (voluntaryCheckbox) voluntaryCheckbox.checked = false;
const voluntaryCard = document.getElementById('voluntary-card');
if (voluntaryCard) voluntaryCard.classList.remove('checked');
// Show/hide voluntary compliance section
const voluntarySection = document.getElementById('voluntary-section');
if (result.classification === 'not_applicable') {
voluntarySection.classList.add('visible');
} else {
voluntarySection.classList.remove('visible');
}
// Update classification UI
const container = document.getElementById('classification-result');
const labelEl = document.getElementById('classification-label');
@@ -1589,6 +1765,32 @@
document.getElementById('sum-turnover').textContent = turnover ? '\u20AC ' + turnover.toLocaleString('it-IT') : '-';
}
// ── Voluntary Compliance Toggle ───────────────────────────────
function onVoluntaryChange(checked) {
wizardState.voluntaryCompliance = checked;
const card = document.getElementById('voluntary-card');
const container = document.getElementById('classification-result');
const labelEl = document.getElementById('classification-label');
const sumEntityType = document.getElementById('sum-entity-type');
card.classList.toggle('checked', checked);
if (checked) {
container.className = 'classification-result voluntary';
container.querySelector('.classification-result-icon').innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/><path d="M10 12.5l-2-2-1.41 1.41L10 15.32l5.41-5.41L14 8.5l-4 4z"/></svg>';
labelEl.textContent = 'Adesione Volontaria';
document.getElementById('classification-desc').textContent = 'La tua organizzazione aderisce volontariamente alla Direttiva NIS2, adottando le misure di sicurezza previste dalla normativa per migliorare la propria postura di cybersicurezza.';
sumEntityType.textContent = 'Adesione Volontaria';
} else {
container.className = 'classification-result not-applicable';
container.querySelector('.classification-result-icon').innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>';
labelEl.textContent = wizardState.classification.label;
document.getElementById('classification-desc').textContent = wizardState.classification.description;
sumEntityType.textContent = wizardState.classification.label;
}
}
// ── Complete Onboarding ─────────────────────────────────────────
async function completeOnboarding() {
const btn = document.getElementById('complete-btn');
@@ -1612,6 +1814,8 @@
sector: c.sector,
employee_count: parseInt(c.employee_count) || 0,
annual_turnover_eur: parseInt(c.annual_turnover) || 0,
// Voluntary compliance
voluntary_compliance: wizardState.voluntaryCompliance ? 1 : 0,
// Profile data
full_name: p.full_name,
phone: p.phone,
+44 -4
View File
@@ -79,6 +79,11 @@
color: var(--gray-600);
border: 1px solid var(--gray-300);
}
.entity-badge.voluntary {
background: var(--primary-bg);
color: var(--primary);
border: 1px solid var(--primary-light);
}
.plan-badge {
display: inline-flex;
align-items: center;
@@ -573,10 +578,14 @@
function renderEntityClassification(org) {
const type = org.entity_type || 'not_applicable';
const label = entityTypeLabels[type] || 'Non Applicabile';
const isVoluntary = org.voluntary_compliance == 1 && type === 'not_applicable';
const displayLabel = isVoluntary ? 'Adesione Volontaria' : (entityTypeLabels[type] || 'Non Applicabile');
const badgeClass = isVoluntary ? 'voluntary' : type;
let description = '';
if (type === 'essential') {
if (isVoluntary) {
description = 'L\'organizzazione ha scelto di aderire <strong>volontariamente</strong> ai requisiti della Direttiva NIS2, pur non rientrando nell\'ambito di applicazione obbligatorio. Le misure di sicurezza sono adottate su base volontaria.';
} else if (type === 'essential') {
description = 'L\'organizzazione rientra tra le entita\' <strong>Essenziali</strong> ai sensi della Direttiva NIS2. Soggetta a supervisione proattiva con sanzioni fino a EUR 10M o 2% del fatturato globale.';
} else if (type === 'important') {
description = 'L\'organizzazione rientra tra le entita\' <strong>Importanti</strong> ai sensi della Direttiva NIS2. Soggetta a supervisione reattiva con sanzioni fino a EUR 7M o 1,4% del fatturato globale.';
@@ -584,15 +593,46 @@
description = 'In base ai parametri attuali, l\'organizzazione <strong>non rientra</strong> nell\'ambito di applicazione della Direttiva NIS2. Si consiglia comunque di adottare le best practice di cybersecurity.';
}
// Voluntary toggle (only visible for not_applicable entities)
const voluntaryToggle = type === 'not_applicable' ? `
<div style="margin-top:16px; padding:16px; background:var(--gray-50); border-radius:var(--border-radius); border:1px solid var(--gray-200);">
<label style="display:flex; align-items:center; gap:10px; cursor:pointer;">
<input type="checkbox" id="org-voluntary" ${isVoluntary ? 'checked' : ''}
onchange="toggleVoluntaryCompliance(this.checked)"
style="accent-color:var(--primary); width:18px; height:18px; flex-shrink:0;">
<span style="font-size:0.875rem; font-weight:600; color:var(--gray-700);">Adesione Volontaria alla NIS2</span>
</label>
<p style="font-size:0.75rem; color:var(--gray-500); margin-top:6px; margin-left:28px;">
Aderisci volontariamente ai requisiti NIS2 anche se non obbligato dalla normativa.
</p>
</div>` : '';
document.getElementById('entity-classification').innerHTML = `
<div class="entity-badge ${type}">
<div class="entity-badge ${badgeClass}">
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd"/></svg>
Entita' ${label}
Entita': ${displayLabel}
</div>
<p style="font-size:0.8125rem; color:var(--gray-600); line-height:1.6; max-width:640px;">${description}</p>
${voluntaryToggle}
`;
}
async function toggleVoluntaryCompliance(checked) {
if (!currentOrg) return;
try {
const result = await api.updateOrganization(currentOrg.id, { voluntary_compliance: checked ? 1 : 0 });
if (result.success) {
currentOrg.voluntary_compliance = checked ? 1 : 0;
renderEntityClassification(currentOrg);
showNotification(checked ? 'Adesione volontaria attivata.' : 'Adesione volontaria disattivata.', 'success');
} else {
showNotification(result.message || 'Errore.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
function renderSubscriptionPlan(org) {
const plan = org.subscription_plan || 'free';
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };