[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:
parent
517cab76a5
commit
4e3408e9f6
502
application/controllers/NonConformityController.php
Normal file
502
application/controllers/NonConformityController.php
Normal file
@ -0,0 +1,502 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Non-Conformity & CAPA Controller
|
||||||
|
*
|
||||||
|
* Gestione non conformità e azioni correttive/preventive.
|
||||||
|
* Predisposizione integrazione SistemiG.agile.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
|
||||||
|
class NonConformityController extends BaseController
|
||||||
|
{
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// NCR CRUD
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ncr/create
|
||||||
|
*/
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
$this->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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -170,8 +170,12 @@ class OnboardingController extends BaseController
|
|||||||
(float) ($orgData['annual_turnover_eur'] ?? 0)
|
(float) ($orgData['annual_turnover_eur'] ?? 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle voluntary compliance
|
||||||
|
$voluntaryCompliance = ($entityType === 'not_applicable' && (int) $this->getParam('voluntary_compliance', 0)) ? 1 : 0;
|
||||||
|
|
||||||
Database::update('organizations', [
|
Database::update('organizations', [
|
||||||
'entity_type' => $entityType,
|
'entity_type' => $entityType,
|
||||||
|
'voluntary_compliance' => $voluntaryCompliance,
|
||||||
], 'id = ?', [$orgId]);
|
], 'id = ?', [$orgId]);
|
||||||
|
|
||||||
// Link user as org_admin
|
// Link user as org_admin
|
||||||
@ -210,6 +214,7 @@ class OnboardingController extends BaseController
|
|||||||
'organization_id' => $orgId,
|
'organization_id' => $orgId,
|
||||||
'name' => $orgData['name'],
|
'name' => $orgData['name'],
|
||||||
'entity_type' => $entityType,
|
'entity_type' => $entityType,
|
||||||
|
'voluntary_compliance' => $voluntaryCompliance,
|
||||||
'classification' => $this->getClassificationDetails($entityType, $orgData['sector'], (int)($orgData['employee_count'] ?? 0), (float)($orgData['annual_turnover_eur'] ?? 0)),
|
'classification' => $this->getClassificationDetails($entityType, $orgData['sector'], (int)($orgData['employee_count'] ?? 0), (float)($orgData['annual_turnover_eur'] ?? 0)),
|
||||||
], 'Onboarding completato', 201);
|
], 'Onboarding completato', 201);
|
||||||
|
|
||||||
|
|||||||
@ -154,7 +154,7 @@ class OrganizationController extends BaseController
|
|||||||
$allowedFields = [
|
$allowedFields = [
|
||||||
'name', 'vat_number', 'fiscal_code', 'sector', 'employee_count',
|
'name', 'vat_number', 'fiscal_code', 'sector', 'employee_count',
|
||||||
'annual_turnover_eur', 'country', 'city', 'address', 'website',
|
'annual_turnover_eur', 'country', 'city', 'address', 'website',
|
||||||
'contact_email', 'contact_phone',
|
'contact_email', 'contact_phone', 'voluntary_compliance',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($allowedFields as $field) {
|
foreach ($allowedFields as $field) {
|
||||||
@ -170,11 +170,25 @@ class OrganizationController extends BaseController
|
|||||||
// Ri-classifica se cambiano settore, dipendenti o fatturato
|
// Ri-classifica se cambiano settore, dipendenti o fatturato
|
||||||
if (isset($updates['sector']) || isset($updates['employee_count']) || isset($updates['annual_turnover_eur'])) {
|
if (isset($updates['sector']) || isset($updates['employee_count']) || isset($updates['annual_turnover_eur'])) {
|
||||||
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$id]);
|
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$id]);
|
||||||
$updates['entity_type'] = $this->classifyNis2Entity(
|
$newEntityType = $this->classifyNis2Entity(
|
||||||
$updates['sector'] ?? $org['sector'],
|
$updates['sector'] ?? $org['sector'],
|
||||||
(int) ($updates['employee_count'] ?? $org['employee_count']),
|
(int) ($updates['employee_count'] ?? $org['employee_count']),
|
||||||
(float) ($updates['annual_turnover_eur'] ?? $org['annual_turnover_eur'])
|
(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]);
|
Database::update('organizations', $updates, 'id = ?', [$id]);
|
||||||
@ -306,6 +320,7 @@ class OrganizationController extends BaseController
|
|||||||
'sector' => $sector,
|
'sector' => $sector,
|
||||||
'employee_count' => $employees,
|
'employee_count' => $employees,
|
||||||
'annual_turnover_eur' => $turnover,
|
'annual_turnover_eur' => $turnover,
|
||||||
|
'allows_voluntary' => $entityType === 'not_applicable',
|
||||||
'explanation' => $this->getClassificationExplanation($entityType, $sector, $employees, $turnover),
|
'explanation' => $this->getClassificationExplanation($entityType, $sector, $employees, $turnover),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,6 +62,8 @@ services:
|
|||||||
- nis2-db-data:/var/lib/mysql
|
- nis2-db-data:/var/lib/mysql
|
||||||
- ../docs/sql/001_initial_schema.sql:/docker-entrypoint-initdb.d/001_initial_schema.sql:ro
|
- ../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/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:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-rootpass}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-rootpass}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
7
docs/sql/003_voluntary_compliance.sql
Normal file
7
docs/sql/003_voluntary_compliance.sql
Normal file
@ -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;
|
||||||
94
docs/sql/004_ncr_capa.sql
Normal file
94
docs/sql/004_ncr_capa.sql
Normal file
@ -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;
|
||||||
@ -99,6 +99,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body" id="ai-analysis-content"></div>
|
<div class="card-body" id="ai-analysis-content"></div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@ -590,6 +609,54 @@
|
|||||||
showNotification('Errore di connessione.', 'error');
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -98,6 +98,7 @@ $controllerMap = [
|
|||||||
'audit' => 'AuditController',
|
'audit' => 'AuditController',
|
||||||
'admin' => 'AdminController',
|
'admin' => 'AdminController',
|
||||||
'onboarding' => 'OnboardingController',
|
'onboarding' => 'OnboardingController',
|
||||||
|
'ncr' => 'NonConformityController',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!isset($controllerMap[$controllerName])) {
|
if (!isset($controllerMap[$controllerName])) {
|
||||||
@ -275,6 +276,20 @@ $actionMap = [
|
|||||||
'POST:fetchCompany' => 'fetchCompany',
|
'POST:fetchCompany' => 'fetchCompany',
|
||||||
'POST:complete' => 'complete',
|
'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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@ -264,6 +264,19 @@ class NIS2API {
|
|||||||
|
|
||||||
fetchCompany(vatNumber) { return this.post('/onboarding/fetch-company', { vat_number: vatNumber }); }
|
fetchCompany(vatNumber) { return this.post('/onboarding/fetch-company', { vat_number: vatNumber }); }
|
||||||
completeOnboarding(data) { return this.post('/onboarding/complete', data); }
|
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
|
// Singleton globale
|
||||||
|
|||||||
@ -598,6 +598,91 @@
|
|||||||
gap: 12px;
|
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 ─────────────────────────────────────────────── */
|
/* ── Responsive ─────────────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.wizard-header {
|
.wizard-header {
|
||||||
@ -1040,6 +1125,22 @@
|
|||||||
<div class="classification-result-desc" id="classification-desc"></div>
|
<div class="classification-result-desc" id="classification-desc"></div>
|
||||||
</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 -->
|
<!-- Summary -->
|
||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
<div class="summary-card-header">Riepilogo Dati Aziendali</div>
|
<div class="summary-card-header">Riepilogo Dati Aziendali</div>
|
||||||
@ -1142,9 +1243,22 @@
|
|||||||
role: '',
|
role: '',
|
||||||
phone: ''
|
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 ────────────────────────────────────
|
// ── Step 1: Method selection ────────────────────────────────────
|
||||||
function selectMethod(method) {
|
function selectMethod(method) {
|
||||||
wizardState.method = method;
|
wizardState.method = method;
|
||||||
@ -1308,11 +1422,24 @@
|
|||||||
wizardState.company.website = d.website || d.sito_web || '';
|
wizardState.company.website = d.website || d.sito_web || '';
|
||||||
wizardState.company.email = d.email || d.pec || '';
|
wizardState.company.email = d.email || d.pec || '';
|
||||||
wizardState.company.phone = d.phone || d.telefono || '';
|
wizardState.company.phone = d.phone || d.telefono || '';
|
||||||
wizardState.company.sector = d.sector || '';
|
wizardState.company.sector = d.suggested_sector || d.sector || '';
|
||||||
wizardState.company.employee_count = d.employee_count || d.employees || '';
|
wizardState.company.employee_count = parseEmployeesRange(d.employees_range) || d.employee_count || d.employees || '';
|
||||||
wizardState.company.annual_turnover = d.annual_turnover || d.turnover || '';
|
// Fatturato resta SEMPRE manuale — non auto-compilare dalla visura
|
||||||
|
wizardState.company.annual_turnover = '';
|
||||||
wizardState.dataSource = 'Dati precompilati dalla visura camerale';
|
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');
|
showNotification('Visura analizzata con successo!', 'success');
|
||||||
|
|
||||||
// Auto-advance to step 3
|
// Auto-advance to step 3
|
||||||
@ -1361,11 +1488,24 @@
|
|||||||
wizardState.company.website = d.website || d.sito_web || '';
|
wizardState.company.website = d.website || d.sito_web || '';
|
||||||
wizardState.company.email = d.email || d.pec || '';
|
wizardState.company.email = d.email || d.pec || '';
|
||||||
wizardState.company.phone = d.phone || d.telefono || '';
|
wizardState.company.phone = d.phone || d.telefono || '';
|
||||||
wizardState.company.sector = d.sector || '';
|
wizardState.company.sector = d.suggested_sector || d.sector || '';
|
||||||
wizardState.company.employee_count = d.employee_count || d.employees || '';
|
wizardState.company.employee_count = parseEmployeesRange(d.employees_range) || d.employee_count || d.employees || '';
|
||||||
wizardState.company.annual_turnover = d.annual_turnover || d.turnover || '';
|
// Fatturato resta SEMPRE manuale
|
||||||
|
wizardState.company.annual_turnover = '';
|
||||||
wizardState.dataSource = 'Dati recuperati da CertiSource';
|
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');
|
showNotification('Dati aziendali recuperati con successo!', 'success');
|
||||||
|
|
||||||
// Auto-advance to step 3
|
// Auto-advance to step 3
|
||||||
@ -1413,6 +1553,27 @@
|
|||||||
} else {
|
} else {
|
||||||
banner.classList.remove('visible');
|
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() {
|
function submitStep3() {
|
||||||
@ -1553,6 +1714,21 @@
|
|||||||
const result = classifyLocally(sector, employees, turnover);
|
const result = classifyLocally(sector, employees, turnover);
|
||||||
wizardState.classification = result;
|
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
|
// Update classification UI
|
||||||
const container = document.getElementById('classification-result');
|
const container = document.getElementById('classification-result');
|
||||||
const labelEl = document.getElementById('classification-label');
|
const labelEl = document.getElementById('classification-label');
|
||||||
@ -1589,6 +1765,32 @@
|
|||||||
document.getElementById('sum-turnover').textContent = turnover ? '\u20AC ' + turnover.toLocaleString('it-IT') : '-';
|
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 ─────────────────────────────────────────
|
// ── Complete Onboarding ─────────────────────────────────────────
|
||||||
async function completeOnboarding() {
|
async function completeOnboarding() {
|
||||||
const btn = document.getElementById('complete-btn');
|
const btn = document.getElementById('complete-btn');
|
||||||
@ -1612,6 +1814,8 @@
|
|||||||
sector: c.sector,
|
sector: c.sector,
|
||||||
employee_count: parseInt(c.employee_count) || 0,
|
employee_count: parseInt(c.employee_count) || 0,
|
||||||
annual_turnover_eur: parseInt(c.annual_turnover) || 0,
|
annual_turnover_eur: parseInt(c.annual_turnover) || 0,
|
||||||
|
// Voluntary compliance
|
||||||
|
voluntary_compliance: wizardState.voluntaryCompliance ? 1 : 0,
|
||||||
// Profile data
|
// Profile data
|
||||||
full_name: p.full_name,
|
full_name: p.full_name,
|
||||||
phone: p.phone,
|
phone: p.phone,
|
||||||
|
|||||||
@ -79,6 +79,11 @@
|
|||||||
color: var(--gray-600);
|
color: var(--gray-600);
|
||||||
border: 1px solid var(--gray-300);
|
border: 1px solid var(--gray-300);
|
||||||
}
|
}
|
||||||
|
.entity-badge.voluntary {
|
||||||
|
background: var(--primary-bg);
|
||||||
|
color: var(--primary);
|
||||||
|
border: 1px solid var(--primary-light);
|
||||||
|
}
|
||||||
.plan-badge {
|
.plan-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -573,10 +578,14 @@
|
|||||||
|
|
||||||
function renderEntityClassification(org) {
|
function renderEntityClassification(org) {
|
||||||
const type = org.entity_type || 'not_applicable';
|
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 = '';
|
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.';
|
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') {
|
} 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.';
|
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.';
|
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 = `
|
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>
|
<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>
|
</div>
|
||||||
<p style="font-size:0.8125rem; color:var(--gray-600); line-height:1.6; max-width:640px;">${description}</p>
|
<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) {
|
function renderSubscriptionPlan(org) {
|
||||||
const plan = org.subscription_plan || 'free';
|
const plan = org.subscription_plan || 'free';
|
||||||
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };
|
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user