From 4e3408e9f6692a128091a4e59e0d07c887c557e0 Mon Sep 17 00:00:00 2001 From: Cristiano Benassati Date: Wed, 18 Feb 2026 08:12:57 +0100 Subject: [PATCH] [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 --- .../controllers/NonConformityController.php | 502 ++++++++++++++++++ .../controllers/OnboardingController.php | 13 +- .../controllers/OrganizationController.php | 27 +- docker/docker-compose.yml | 2 + docs/sql/003_voluntary_compliance.sql | 7 + docs/sql/004_ncr_capa.sql | 94 ++++ public/assessment.html | 67 +++ public/index.php | 15 + public/js/api.js | 13 + public/onboarding.html | 218 +++++++- public/settings.html | 48 +- 11 files changed, 985 insertions(+), 21 deletions(-) create mode 100644 application/controllers/NonConformityController.php create mode 100644 docs/sql/003_voluntary_compliance.sql create mode 100644 docs/sql/004_ncr_capa.sql diff --git a/application/controllers/NonConformityController.php b/application/controllers/NonConformityController.php new file mode 100644 index 0000000..085b213 --- /dev/null +++ b/application/controllers/NonConformityController.php @@ -0,0 +1,502 @@ +requireOrgRole(['org_admin', 'compliance_manager', 'auditor']); + $this->validateRequired(['title', 'source']); + + $orgId = $this->getCurrentOrgId(); + $ncrCode = $this->generateCode('NCR'); + + $ncrId = Database::insert('non_conformities', [ + 'organization_id' => $orgId, + 'ncr_code' => $ncrCode, + 'title' => trim($this->getParam('title')), + 'description' => $this->getParam('description'), + 'source' => $this->getParam('source'), + 'source_entity_type' => $this->getParam('source_entity_type'), + 'source_entity_id' => $this->getParam('source_entity_id'), + 'severity' => $this->getParam('severity', 'minor'), + 'category' => $this->getParam('category'), + 'nis2_article' => $this->getParam('nis2_article'), + 'identified_by' => $this->getCurrentUserId(), + 'assigned_to' => $this->getParam('assigned_to'), + 'target_close_date' => $this->getParam('target_close_date'), + ]); + + $this->logAudit('ncr_created', 'non_conformity', $ncrId, [ + 'ncr_code' => $ncrCode, + 'source' => $this->getParam('source'), + ]); + + $this->jsonSuccess([ + 'id' => $ncrId, + 'ncr_code' => $ncrCode, + ], 'Non conformita\' creata', 201); + } + + /** + * GET /api/ncr/list + */ + public function list(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + [$page, $perPage] = $this->getPagination(); + $offset = ($page - 1) * $perPage; + + // Filters + $where = 'n.organization_id = ?'; + $params = [$orgId]; + + $status = $this->getParam('status'); + if ($status) { + $where .= ' AND n.status = ?'; + $params[] = $status; + } + + $severity = $this->getParam('severity'); + if ($severity) { + $where .= ' AND n.severity = ?'; + $params[] = $severity; + } + + $source = $this->getParam('source'); + if ($source) { + $where .= ' AND n.source = ?'; + $params[] = $source; + } + + $total = Database::count('non_conformities n', $where, $params); + + $ncrs = Database::fetchAll( + "SELECT n.*, u1.full_name as identified_by_name, u2.full_name as assigned_to_name, + (SELECT COUNT(*) FROM capa_actions WHERE ncr_id = n.id) as capa_count + FROM non_conformities n + LEFT JOIN users u1 ON u1.id = n.identified_by + LEFT JOIN users u2 ON u2.id = n.assigned_to + WHERE {$where} + ORDER BY n.created_at DESC + LIMIT {$perPage} OFFSET {$offset}", + $params + ); + + $this->jsonPaginated($ncrs, $total, $page, $perPage); + } + + /** + * GET /api/ncr/{id} + */ + public function get(int $id): void + { + $this->requireOrgAccess(); + + $ncr = Database::fetchOne( + 'SELECT n.*, u1.full_name as identified_by_name, u2.full_name as assigned_to_name + FROM non_conformities n + LEFT JOIN users u1 ON u1.id = n.identified_by + LEFT JOIN users u2 ON u2.id = n.assigned_to + WHERE n.id = ? AND n.organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + + if (!$ncr) { + $this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND'); + } + + // Load CAPA actions + $ncr['capa_actions'] = Database::fetchAll( + 'SELECT ca.*, u1.full_name as responsible_name, u2.full_name as verified_by_name + FROM capa_actions ca + LEFT JOIN users u1 ON u1.id = ca.responsible_user_id + LEFT JOIN users u2 ON u2.id = ca.verified_by + WHERE ca.ncr_id = ? + ORDER BY ca.created_at ASC', + [$id] + ); + + $this->jsonSuccess($ncr); + } + + /** + * PUT /api/ncr/{id} + */ + public function update(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']); + + $ncr = Database::fetchOne( + 'SELECT id FROM non_conformities WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + + if (!$ncr) { + $this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND'); + } + + $allowedFields = [ + 'title', 'description', 'severity', 'category', 'nis2_article', + 'status', 'assigned_to', 'target_close_date', 'actual_close_date', + 'root_cause_analysis', 'root_cause_method', + ]; + + $updates = []; + foreach ($allowedFields as $field) { + if ($this->hasParam($field)) { + $updates[$field] = $this->getParam($field); + } + } + + if (empty($updates)) { + $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES'); + } + + // Auto-set close date when status becomes closed + if (isset($updates['status']) && $updates['status'] === 'closed' && !isset($updates['actual_close_date'])) { + $updates['actual_close_date'] = date('Y-m-d'); + } + + Database::update('non_conformities', $updates, 'id = ?', [$id]); + + $this->logAudit('ncr_updated', 'non_conformity', $id, $updates); + + $this->jsonSuccess($updates, 'Non conformita\' aggiornata'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // CAPA ACTIONS + // ═══════════════════════════════════════════════════════════════════════ + + /** + * POST /api/ncr/{id}/capa + */ + public function addCapa(int $ncrId): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']); + $this->validateRequired(['title', 'action_type']); + + $orgId = $this->getCurrentOrgId(); + + // Verify NCR belongs to org + $ncr = Database::fetchOne( + 'SELECT id FROM non_conformities WHERE id = ? AND organization_id = ?', + [$ncrId, $orgId] + ); + + if (!$ncr) { + $this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND'); + } + + $capaCode = $this->generateCode('CAPA'); + + $capaId = Database::insert('capa_actions', [ + 'ncr_id' => $ncrId, + 'organization_id' => $orgId, + 'capa_code' => $capaCode, + 'action_type' => $this->getParam('action_type', 'corrective'), + 'title' => trim($this->getParam('title')), + 'description' => $this->getParam('description'), + 'responsible_user_id' => $this->getParam('responsible_user_id'), + 'due_date' => $this->getParam('due_date'), + 'notes' => $this->getParam('notes'), + ]); + + // Update NCR status to action_planned if still open/investigating + $ncrStatus = Database::fetchOne('SELECT status FROM non_conformities WHERE id = ?', [$ncrId]); + if ($ncrStatus && in_array($ncrStatus['status'], ['open', 'investigating'])) { + Database::update('non_conformities', ['status' => 'action_planned'], 'id = ?', [$ncrId]); + } + + $this->logAudit('capa_created', 'capa_action', $capaId, [ + 'ncr_id' => $ncrId, + 'capa_code' => $capaCode, + ]); + + $this->jsonSuccess([ + 'id' => $capaId, + 'capa_code' => $capaCode, + ], 'Azione CAPA creata', 201); + } + + /** + * PUT /api/ncr/capa/{subId} + */ + public function updateCapa(int $capaId): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']); + + $capa = Database::fetchOne( + 'SELECT id, ncr_id FROM capa_actions WHERE id = ? AND organization_id = ?', + [$capaId, $this->getCurrentOrgId()] + ); + + if (!$capa) { + $this->jsonError('Azione CAPA non trovata', 404, 'CAPA_NOT_FOUND'); + } + + $allowedFields = [ + 'title', 'description', 'action_type', 'status', + 'responsible_user_id', 'due_date', 'completion_date', + 'verification_date', 'verified_by', 'effectiveness_review', + 'is_effective', 'notes', + ]; + + $updates = []; + foreach ($allowedFields as $field) { + if ($this->hasParam($field)) { + $updates[$field] = $this->getParam($field); + } + } + + if (empty($updates)) { + $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES'); + } + + // Auto-set completion date + if (isset($updates['status']) && $updates['status'] === 'completed' && !isset($updates['completion_date'])) { + $updates['completion_date'] = date('Y-m-d'); + } + + Database::update('capa_actions', $updates, 'id = ?', [$capaId]); + + $this->logAudit('capa_updated', 'capa_action', $capaId, $updates); + + $this->jsonSuccess($updates, 'Azione CAPA aggiornata'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // BULK CREATE FROM ASSESSMENT + // ═══════════════════════════════════════════════════════════════════════ + + /** + * POST /api/ncr/fromAssessment + * Genera NCR dai gap (risposte not_implemented/partial) di un assessment + */ + public function fromAssessment(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['assessment_id']); + + $orgId = $this->getCurrentOrgId(); + $assessmentId = (int) $this->getParam('assessment_id'); + + // Verify assessment belongs to org + $assessment = Database::fetchOne( + 'SELECT id, title FROM assessments WHERE id = ? AND organization_id = ?', + [$assessmentId, $orgId] + ); + + if (!$assessment) { + $this->jsonError('Assessment non trovato', 404, 'ASSESSMENT_NOT_FOUND'); + } + + // Check for existing NCRs from this assessment to avoid duplicates + $existingCount = Database::count( + 'non_conformities', + 'organization_id = ? AND source = ? AND source_entity_type = ? AND source_entity_id IN (SELECT id FROM assessment_responses WHERE assessment_id = ?)', + [$orgId, 'assessment', 'assessment_response', $assessmentId] + ); + + if ($existingCount > 0) { + $this->jsonError( + "Esistono gia' {$existingCount} non conformita' generate da questo assessment", + 409, + 'NCR_ALREADY_GENERATED' + ); + } + + // Find gaps + $gaps = Database::fetchAll( + 'SELECT * FROM assessment_responses + WHERE assessment_id = ? + AND response_value IN ("not_implemented", "partial") + ORDER BY category, question_code', + [$assessmentId] + ); + + if (empty($gaps)) { + $this->jsonSuccess(['created' => [], 'total' => 0], 'Nessun gap trovato nell\'assessment'); + return; + } + + $created = []; + $assessmentTitle = $assessment['title']; + + foreach ($gaps as $gap) { + $severity = $gap['response_value'] === 'not_implemented' ? 'major' : 'minor'; + $ncrCode = $this->generateCode('NCR'); + + $questionText = $gap['question_text'] ?? ''; + $title = mb_strlen($questionText) > 200 + ? 'Gap: ' . mb_substr($questionText, 0, 197) . '...' + : 'Gap: ' . $questionText; + + $ncrId = Database::insert('non_conformities', [ + 'organization_id' => $orgId, + 'ncr_code' => $ncrCode, + 'title' => $title, + 'description' => "Gap identificato nell'assessment \"{$assessmentTitle}\": {$questionText}", + 'source' => 'assessment', + 'source_entity_type' => 'assessment_response', + 'source_entity_id' => $gap['id'], + 'severity' => $severity, + 'category' => $gap['category'] ?? null, + 'nis2_article' => $gap['nis2_article'] ?? null, + 'identified_by' => $this->getCurrentUserId(), + 'status' => 'open', + ]); + + $created[] = [ + 'id' => $ncrId, + 'ncr_code' => $ncrCode, + 'severity' => $severity, + 'category' => $gap['category'] ?? null, + ]; + } + + $this->logAudit('ncr_bulk_created', 'assessment', $assessmentId, [ + 'count' => count($created), + ]); + + $this->jsonSuccess([ + 'created' => $created, + 'total' => count($created), + ], count($created) . ' non conformita\' generate dall\'assessment'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // STATISTICS + // ═══════════════════════════════════════════════════════════════════════ + + /** + * GET /api/ncr/stats + */ + public function stats(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + // NCR counts by status + $byStatus = Database::fetchAll( + 'SELECT status, COUNT(*) as count FROM non_conformities WHERE organization_id = ? GROUP BY status', + [$orgId] + ); + + // NCR counts by severity + $bySeverity = Database::fetchAll( + 'SELECT severity, COUNT(*) as count FROM non_conformities WHERE organization_id = ? GROUP BY severity', + [$orgId] + ); + + // CAPA counts by status + $capaByStatus = Database::fetchAll( + 'SELECT status, COUNT(*) as count FROM capa_actions WHERE organization_id = ? GROUP BY status', + [$orgId] + ); + + // Overdue NCRs + $overdueNcr = Database::count( + 'non_conformities', + 'organization_id = ? AND target_close_date < CURDATE() AND status NOT IN ("closed", "cancelled")', + [$orgId] + ); + + // Overdue CAPAs + $overdueCapa = Database::count( + 'capa_actions', + 'organization_id = ? AND due_date < CURDATE() AND status NOT IN ("completed", "verified")', + [$orgId] + ); + + $totalNcr = Database::count('non_conformities', 'organization_id = ?', [$orgId]); + $openNcr = Database::count('non_conformities', 'organization_id = ? AND status NOT IN ("closed", "cancelled")', [$orgId]); + + $this->jsonSuccess([ + 'total_ncr' => $totalNcr, + 'open_ncr' => $openNcr, + 'overdue_ncr' => $overdueNcr, + 'overdue_capa' => $overdueCapa, + 'ncr_by_status' => $byStatus, + 'ncr_by_severity' => $bySeverity, + 'capa_by_status' => $capaByStatus, + ]); + } + + // ═══════════════════════════════════════════════════════════════════════ + // EXTERNAL INTEGRATION (SistemiG.agile - Future) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * POST /api/ncr/{id}/sync + * Sincronizza NCR con sistema esterno (SistemiG.agile) + */ + public function syncExternal(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $ncr = Database::fetchOne( + 'SELECT * FROM non_conformities WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + + if (!$ncr) { + $this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND'); + } + + $targetUrl = defined('SISTEMIG_API_URL') ? SISTEMIG_API_URL : ''; + if (empty($targetUrl)) { + $this->jsonError('Integrazione SistemiG non configurata', 501, 'INTEGRATION_NOT_CONFIGURED'); + } + + // Placeholder: in future this will POST to SistemiG API + $this->jsonSuccess([ + 'ncr_id' => $id, + 'ncr_code' => $ncr['ncr_code'], + 'sync_status' => 'not_implemented', + 'message' => 'Integrazione SistemiG.agile in fase di sviluppo', + ]); + } + + /** + * POST /api/ncr/webhook + * Webhook per ricezione aggiornamenti da sistema esterno + * Autenticato via API key (X-SistemiG-Key header), non JWT + */ + public function webhook(): void + { + // Authenticate via API key instead of JWT + $apiKey = $_SERVER['HTTP_X_SISTEMIG_KEY'] ?? ''; + $expectedKey = defined('SISTEMIG_API_KEY') ? SISTEMIG_API_KEY : ''; + + if (empty($expectedKey) || $apiKey !== $expectedKey) { + $this->jsonError('Chiave API non valida', 401, 'INVALID_API_KEY'); + } + + // Placeholder: process incoming webhook payload + $payload = $this->getJsonBody(); + + $this->jsonSuccess([ + 'received' => true, + 'message' => 'Webhook ricevuto. Integrazione SistemiG.agile in fase di sviluppo.', + 'payload_keys' => array_keys($payload), + ]); + } +} diff --git a/application/controllers/OnboardingController.php b/application/controllers/OnboardingController.php index 4e2e8b5..9b5cb02 100644 --- a/application/controllers/OnboardingController.php +++ b/application/controllers/OnboardingController.php @@ -170,8 +170,12 @@ class OnboardingController extends BaseController (float) ($orgData['annual_turnover_eur'] ?? 0) ); + // Handle voluntary compliance + $voluntaryCompliance = ($entityType === 'not_applicable' && (int) $this->getParam('voluntary_compliance', 0)) ? 1 : 0; + Database::update('organizations', [ 'entity_type' => $entityType, + 'voluntary_compliance' => $voluntaryCompliance, ], 'id = ?', [$orgId]); // Link user as org_admin @@ -207,10 +211,11 @@ class OnboardingController extends BaseController ]); $this->jsonSuccess([ - 'organization_id' => $orgId, - 'name' => $orgData['name'], - 'entity_type' => $entityType, - 'classification' => $this->getClassificationDetails($entityType, $orgData['sector'], (int)($orgData['employee_count'] ?? 0), (float)($orgData['annual_turnover_eur'] ?? 0)), + 'organization_id' => $orgId, + 'name' => $orgData['name'], + 'entity_type' => $entityType, + 'voluntary_compliance' => $voluntaryCompliance, + 'classification' => $this->getClassificationDetails($entityType, $orgData['sector'], (int)($orgData['employee_count'] ?? 0), (float)($orgData['annual_turnover_eur'] ?? 0)), ], 'Onboarding completato', 201); } catch (Throwable $e) { diff --git a/application/controllers/OrganizationController.php b/application/controllers/OrganizationController.php index 6b1ea89..67beaba 100644 --- a/application/controllers/OrganizationController.php +++ b/application/controllers/OrganizationController.php @@ -154,7 +154,7 @@ class OrganizationController extends BaseController $allowedFields = [ 'name', 'vat_number', 'fiscal_code', 'sector', 'employee_count', 'annual_turnover_eur', 'country', 'city', 'address', 'website', - 'contact_email', 'contact_phone', + 'contact_email', 'contact_phone', 'voluntary_compliance', ]; foreach ($allowedFields as $field) { @@ -170,11 +170,25 @@ class OrganizationController extends BaseController // Ri-classifica se cambiano settore, dipendenti o fatturato if (isset($updates['sector']) || isset($updates['employee_count']) || isset($updates['annual_turnover_eur'])) { $org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$id]); - $updates['entity_type'] = $this->classifyNis2Entity( + $newEntityType = $this->classifyNis2Entity( $updates['sector'] ?? $org['sector'], (int) ($updates['employee_count'] ?? $org['employee_count']), (float) ($updates['annual_turnover_eur'] ?? $org['annual_turnover_eur']) ); + $updates['entity_type'] = $newEntityType; + // Reset voluntary compliance if no longer not_applicable + if ($newEntityType !== 'not_applicable') { + $updates['voluntary_compliance'] = 0; + } + } + + // Ensure voluntary_compliance is only set when entity_type is not_applicable + if (isset($updates['voluntary_compliance'])) { + $currentOrg = $org ?? Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$id]); + $currentType = $updates['entity_type'] ?? $currentOrg['entity_type']; + if ($currentType !== 'not_applicable') { + $updates['voluntary_compliance'] = 0; + } } Database::update('organizations', $updates, 'id = ?', [$id]); @@ -302,11 +316,12 @@ class OrganizationController extends BaseController $entityType = $this->classifyNis2Entity($sector, $employees, $turnover); $this->jsonSuccess([ - 'entity_type' => $entityType, - 'sector' => $sector, - 'employee_count' => $employees, + 'entity_type' => $entityType, + 'sector' => $sector, + 'employee_count' => $employees, 'annual_turnover_eur' => $turnover, - 'explanation' => $this->getClassificationExplanation($entityType, $sector, $employees, $turnover), + 'allows_voluntary' => $entityType === 'not_applicable', + 'explanation' => $this->getClassificationExplanation($entityType, $sector, $employees, $turnover), ]); } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d66aca5..c06e14e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -62,6 +62,8 @@ services: - nis2-db-data:/var/lib/mysql - ../docs/sql/001_initial_schema.sql:/docker-entrypoint-initdb.d/001_initial_schema.sql:ro - ../docs/sql/002_email_log.sql:/docker-entrypoint-initdb.d/002_email_log.sql:ro + - ../docs/sql/003_voluntary_compliance.sql:/docker-entrypoint-initdb.d/003_voluntary_compliance.sql:ro + - ../docs/sql/004_ncr_capa.sql:/docker-entrypoint-initdb.d/004_ncr_capa.sql:ro healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-rootpass}"] interval: 10s diff --git a/docs/sql/003_voluntary_compliance.sql b/docs/sql/003_voluntary_compliance.sql new file mode 100644 index 0000000..c238069 --- /dev/null +++ b/docs/sql/003_voluntary_compliance.sql @@ -0,0 +1,7 @@ +-- ═══════════════════════════════════════════════════════════════════ +-- NIS2 Agile - Migration 003: Voluntary Compliance +-- Aggiunge colonna per adesione volontaria NIS2 +-- ═══════════════════════════════════════════════════════════════════ + +ALTER TABLE organizations +ADD COLUMN voluntary_compliance TINYINT(1) NOT NULL DEFAULT 0 AFTER entity_type; diff --git a/docs/sql/004_ncr_capa.sql b/docs/sql/004_ncr_capa.sql new file mode 100644 index 0000000..15c5e77 --- /dev/null +++ b/docs/sql/004_ncr_capa.sql @@ -0,0 +1,94 @@ +-- ═══════════════════════════════════════════════════════════════════ +-- NIS2 Agile - Migration 004: Non-Conformity & CAPA +-- Tabelle per gestione non conformità e azioni correttive +-- Predisposizione integrazione SistemiG.agile +-- ═══════════════════════════════════════════════════════════════════ + +-- Non-Conformity Reports (NCR) +CREATE TABLE IF NOT EXISTS non_conformities ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + ncr_code VARCHAR(20) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + source ENUM( + 'assessment', 'audit', 'incident', 'supplier_review', + 'management_review', 'external_audit', 'other' + ) NOT NULL DEFAULT 'assessment', + -- Link to source entity + source_entity_type VARCHAR(50), + source_entity_id INT, + -- Classification + severity ENUM('minor', 'major', 'critical', 'observation') NOT NULL DEFAULT 'minor', + category VARCHAR(100), + nis2_article VARCHAR(20), + -- Lifecycle + status ENUM( + 'open', 'investigating', 'action_planned', + 'correcting', 'verifying', 'closed', 'cancelled' + ) NOT NULL DEFAULT 'open', + -- Ownership + identified_by INT, + assigned_to INT, + -- Dates + identified_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + target_close_date DATE, + actual_close_date DATE, + -- Root Cause Analysis + root_cause_analysis TEXT, + root_cause_method ENUM('five_whys', 'fishbone', 'fta', 'other'), + -- External integration (SistemiG.agile) + external_system VARCHAR(50), + external_id VARCHAR(100), + external_synced_at DATETIME, + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (identified_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (assigned_to) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_ncr_org (organization_id), + INDEX idx_ncr_status (status), + INDEX idx_ncr_severity (severity), + INDEX idx_ncr_source (source_entity_type, source_entity_id), + INDEX idx_ncr_external (external_system, external_id), + UNIQUE KEY uk_org_ncr_code (organization_id, ncr_code) +) ENGINE=InnoDB; + +-- Corrective and Preventive Actions (CAPA) +CREATE TABLE IF NOT EXISTS capa_actions ( + id INT AUTO_INCREMENT PRIMARY KEY, + ncr_id INT NOT NULL, + organization_id INT NOT NULL, + capa_code VARCHAR(20) NOT NULL, + action_type ENUM('corrective', 'preventive', 'containment') NOT NULL DEFAULT 'corrective', + title VARCHAR(255) NOT NULL, + description TEXT, + -- Lifecycle + status ENUM('planned', 'in_progress', 'completed', 'verified', 'ineffective') NOT NULL DEFAULT 'planned', + -- Ownership & Dates + responsible_user_id INT, + due_date DATE, + completion_date DATE, + verification_date DATE, + verified_by INT, + -- Effectiveness + effectiveness_review TEXT, + is_effective TINYINT(1), + -- External integration (SistemiG.agile) + external_system VARCHAR(50), + external_id VARCHAR(100), + -- Metadata + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (ncr_id) REFERENCES non_conformities(id) ON DELETE CASCADE, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (verified_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_capa_ncr (ncr_id), + INDEX idx_capa_org (organization_id), + INDEX idx_capa_status (status), + INDEX idx_capa_due (due_date), + UNIQUE KEY uk_org_capa_code (organization_id, capa_code) +) ENGINE=InnoDB; diff --git a/public/assessment.html b/public/assessment.html index 3e483eb..30c671e 100644 --- a/public/assessment.html +++ b/public/assessment.html @@ -99,6 +99,25 @@
+ + +
+
+

Non Conformita'

+
+
+

+ Genera automaticamente le non conformita' (NCR) dai gap identificati nell'assessment. + I controlli con stato "Non Implementato" saranno classificati come major, + quelli "Parzialmente Implementato" come minor. +

+ + +
+
@@ -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 = '
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 = ` +
+ ${total} non conformita' generate con successo +
+ ${majorCount > 0 ? `${majorCount} major ` : ''} + ${minorCount > 0 ? `${minorCount} minor` : ''} +
+
`; + showNotification(`${total} non conformita' generate.`, 'success'); + } else { + resultDiv.innerHTML = '

Nessun gap trovato — tutti i controlli sono implementati.

'; + showNotification('Nessun gap trovato.', 'info'); + } + btn.style.display = 'none'; + } else { + resultDiv.innerHTML = `

${escapeHtml(result.message || 'Errore nella generazione.')}

`; + showNotification(result.message || 'Errore.', 'error'); + btn.disabled = false; + btn.innerHTML = ' Genera Non Conformita\' dai Gap'; + } + } catch (e) { + showNotification('Errore di connessione.', 'error'); + btn.disabled = false; + btn.innerHTML = ' Genera Non Conformita\' dai Gap'; + } + } diff --git a/public/index.php b/public/index.php index cc63469..29d012f 100644 --- a/public/index.php +++ b/public/index.php @@ -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', + ], ]; // ═══════════════════════════════════════════════════════════════════════════ diff --git a/public/js/api.js b/public/js/api.js index e2e4e04..02311d3 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -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 diff --git a/public/onboarding.html b/public/onboarding.html index cf6b923..4439d27 100644 --- a/public/onboarding.html +++ b/public/onboarding.html @@ -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 @@
+ +
+
+ +
+

Adesione Volontaria alla NIS2

+

+ 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. +

+
+
+
+
Riepilogo Dati Aziendali
@@ -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 = ''; + 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 = ''; + 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, diff --git a/public/settings.html b/public/settings.html index a78c2fd..d67aab1 100644 --- a/public/settings.html +++ b/public/settings.html @@ -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 volontariamente 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\' Essenziali 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\' Importanti 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 non rientra 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' ? ` +
+ +

+ Aderisci volontariamente ai requisiti NIS2 anche se non obbligato dalla normativa. +

+
` : ''; + document.getElementById('entity-classification').innerHTML = ` -
+
- Entita' ${label} + Entita': ${displayLabel}

${description}

+ ${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' };