[FEAT] ISO-readiness: Audit interni (§9.2) + Riesame Direzione (§9.3) + Calendario scadenze (mig.055-056, v1.22.0)
Rilascio unico (3 moduli) costruito in flotta parallela + verifica avversariale. MODULO A — Audit interni (ISO 27001 §9.2, mig.055): internal_audits + internal_audit_items; codice AUD-NNN; checklist pre-popolata (clausole 4-10 + Annex A applicabili dal SoA); esiti per riga; apertura non conformità collegata a NCR/CAPA (source polimorfico); report HTML stampabile. InternalAuditController + internal-audits.html. MODULO B — Riesame di Direzione (ISO 27001 §9.3, mig.056): management_reviews + management_review_decisions; codice RD-AAAA-NN; INPUT aggregati automaticamente dai moduli (gather: NC/CAPA, audit interni, rischi+trattamenti, KPI/score, obiettivi, formazione, stakeholder, normative, scadenze), congelati nello snapshot all'approvazione; decisioni; verbale stampabile. ManagementReviewController + management-review.html. MODULO C — Calendario unico scadenze: aggregatore SOLA LETTURA (nessuna migrazione) di tutte le scadenze (incidenti/policy/rischi/NC-CAPA/formazione/stakeholder/audit/riesame/review_schedule), griglia mensile + lista + filtri + deep-link. CalendarController + calendario.html. Integrazione: router (3 controller+actionMap), api.js, sidebar V2+legacy, help.js (3 sezioni), i18n (IT+EN), review_schedule ENUM += internal_audit. v1.22.0 + sw cache + cache-buster 20260630. Verifica flotta (28 agenti, 4 dim + avversariale): 9 finding confermati, TUTTI corretti — MAJOR gatherRisks (risk_treatments.organization_id inesistente → JOIN risks); nextCode numerico (no dup >99/anno); footer report audit con etichetta "ISO buona prassi, non obbligo"; help 24→25 clausole; ARIA tab/calendario; focus modali; tasti su celle calendario; rimossi helper api morti. Smoke prod OK (calendario 18 eventi, audit AUD-001 25 item + report + audit→NCR + audit→calendario, riesame gather/decisione/approve/report, gatherRisks ora available); org 151 ripulita. Additivo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
18a4a0b509
commit
79ca72fba9
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile — DDL idempotente Audit interni (Modulo A, mig.055).
|
||||
* ----------------------------------------------------------------------------
|
||||
* Crea (se mancanti) le tabelle del modulo Audit interni (ISO 27001 §9.2):
|
||||
* - internal_audits (programma/sessioni di audit)
|
||||
* - internal_audit_items (checklist: clausole 4-10, Annex A, NIS2, custom)
|
||||
* NESSUN dato di sistema: le checklist sono pre-popolate per-org dal controller
|
||||
* al create (clausole statiche + Annex A dal SoA dell'org).
|
||||
*
|
||||
* Idempotente: CREATE TABLE IF NOT EXISTS (re-eseguibile senza errore).
|
||||
* Allineato a docs/sql/055_internal_audits.sql.
|
||||
*
|
||||
* Uso (dentro il container app):
|
||||
* docker exec nis2-app php /var/www/nis2-agile/application/cli/seed_internal_audits.php
|
||||
*/
|
||||
|
||||
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("Solo da CLI.\n"); }
|
||||
|
||||
require_once __DIR__ . '/../config/env.php';
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
|
||||
$pdo = Database::getInstance();
|
||||
$pdo->exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
|
||||
$ddl = [
|
||||
"CREATE TABLE IF NOT EXISTS internal_audits (
|
||||
id INT NOT NULL AUTO_INCREMENT, organization_id INT NOT NULL,
|
||||
code VARCHAR(20) NULL, title VARCHAR(255) NOT NULL,
|
||||
scope TEXT NULL, criteria TEXT NULL,
|
||||
planned_date DATE NULL, executed_date DATE NULL,
|
||||
status ENUM('planned','in_progress','completed','cancelled') NOT NULL DEFAULT 'planned',
|
||||
lead_auditor_user_id INT NULL, lead_auditor_role_id INT NULL,
|
||||
conclusion TEXT NULL, created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id), KEY idx_intaud_org (organization_id),
|
||||
CONSTRAINT fk_intaud_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_intaud_lead_user FOREIGN KEY (lead_auditor_user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_intaud_lead_role FOREIGN KEY (lead_auditor_role_id) REFERENCES org_roles (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_intaud_creator FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS internal_audit_items (
|
||||
id INT NOT NULL AUTO_INCREMENT, audit_id INT NOT NULL,
|
||||
ref_type ENUM('clause','annex_control','nis2_measure','custom') NOT NULL DEFAULT 'clause',
|
||||
ref_code VARCHAR(32) NULL, checkpoint TEXT NOT NULL,
|
||||
result ENUM('da_verificare','conforme','non_conforme','osservazione','opportunita','non_applicabile') NOT NULL DEFAULT 'da_verificare',
|
||||
note TEXT NULL, ord INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id), KEY idx_intauditem_audit (audit_id),
|
||||
CONSTRAINT fk_intauditem_audit FOREIGN KEY (audit_id) REFERENCES internal_audits (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($ddl as $stmt) {
|
||||
try { $pdo->exec($stmt); }
|
||||
catch (PDOException $e) { if (!in_array($e->errorInfo[1] ?? 0, [1050, 1061], true)) { throw $e; } }
|
||||
}
|
||||
|
||||
// Estende l'ENUM del calendario (review_schedule) con 'internal_audit' così la
|
||||
// data pianificata dell'audit compare nel calendario NIS2. Idempotente in effetto.
|
||||
// Include i valori già introdotti da seeder precedenti (stakeholder_activity) per
|
||||
// non regredirli; se 'internal_audit' è già presente l'ALTER è un no-op.
|
||||
try {
|
||||
$cur = (string) $pdo->query("SELECT COLUMN_TYPE FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'review_schedule' AND COLUMN_NAME = 'entity_type'")->fetchColumn();
|
||||
if (!str_contains($cur, "'internal_audit'")) {
|
||||
$vals = ['role','skill','inventory','procedure','risk','supplier','measure','custom','stakeholder_activity','internal_audit'];
|
||||
// conserva eventuali valori extra già presenti nell'ENUM corrente
|
||||
if (preg_match_all("/'([^']+)'/", $cur, $m)) {
|
||||
foreach ($m[1] as $v) { if (!in_array($v, $vals, true)) { $vals[] = $v; } }
|
||||
}
|
||||
$enum = "'" . implode("','", $vals) . "'";
|
||||
$pdo->exec("ALTER TABLE review_schedule MODIFY COLUMN entity_type ENUM($enum) NOT NULL");
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, "WARN ALTER review_schedule: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
foreach (['internal_audits', 'internal_audit_items'] as $t) {
|
||||
$counts[$t] = (int) $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn();
|
||||
}
|
||||
$enum = $pdo->query("SELECT COLUMN_TYPE FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'review_schedule' AND COLUMN_NAME = 'entity_type'")->fetchColumn();
|
||||
echo "OK seed-internal-audits — " . json_encode($counts, JSON_UNESCAPED_UNICODE) . "\n";
|
||||
echo "review_schedule.entity_type has internal_audit: " . (str_contains((string) $enum, 'internal_audit') ? 'YES' : 'NO') . "\n";
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile — DDL idempotente Riesame di Direzione (ISO 27001 §9.3, mig.056).
|
||||
* ----------------------------------------------------------------------------
|
||||
* Crea (se mancanti) le tabelle del Modulo B "Riesame di Direzione":
|
||||
* - management_reviews (il verbale: input aggregati congelati + esiti)
|
||||
* - management_review_decisions (decisioni/output → owner ruolo, scadenza, CAPA)
|
||||
*
|
||||
* NESSUN dato di sistema: i riesami sono per-org (creati dall'utente).
|
||||
*
|
||||
* Idempotente: CREATE TABLE IF NOT EXISTS (re-eseguibile senza errore; in caso
|
||||
* di tabella già presente ignora gli errno 1050/1061). Allineato a
|
||||
* docs/sql/056_management_reviews.sql.
|
||||
*
|
||||
* Uso (dentro il container app, o sull'host con la stessa connessione PDO):
|
||||
* docker exec nis2-app php /var/www/nis2-agile/application/cli/seed_management_reviews.php
|
||||
*/
|
||||
|
||||
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("Solo da CLI.\n"); }
|
||||
|
||||
require_once __DIR__ . '/../config/env.php';
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
|
||||
$pdo = Database::getInstance();
|
||||
$pdo->exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
|
||||
$ddl = [
|
||||
"CREATE TABLE IF NOT EXISTS management_reviews (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
organization_id INT NOT NULL,
|
||||
code VARCHAR(20) NULL,
|
||||
review_date DATE NULL,
|
||||
period_label VARCHAR(100) NULL,
|
||||
chair_user_id INT NULL,
|
||||
attendees JSON NULL,
|
||||
status ENUM('draft','approved') NOT NULL DEFAULT 'draft',
|
||||
approved_by INT NULL,
|
||||
approved_at DATETIME NULL,
|
||||
snapshot JSON NULL,
|
||||
conclusions TEXT NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_mgr_org (organization_id),
|
||||
CONSTRAINT fk_mgr_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mgr_chair FOREIGN KEY (chair_user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_mgr_approved FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_mgr_creator FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS management_review_decisions (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
review_id INT NOT NULL,
|
||||
decision TEXT NOT NULL,
|
||||
owner_role_id INT NULL,
|
||||
due_date DATE NULL,
|
||||
status ENUM('open','in_progress','done') NOT NULL DEFAULT 'open',
|
||||
capa_id INT NULL,
|
||||
ord INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_mrd_review (review_id),
|
||||
CONSTRAINT fk_mrd_review FOREIGN KEY (review_id) REFERENCES management_reviews (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mrd_owner FOREIGN KEY (owner_role_id) REFERENCES org_roles (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($ddl as $stmt) {
|
||||
try { $pdo->exec($stmt); }
|
||||
catch (PDOException $e) { if (!in_array($e->errorInfo[1] ?? 0, [1050, 1061], true)) { throw $e; } }
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
foreach (['management_reviews', 'management_review_decisions'] as $t) {
|
||||
$counts[$t] = (int) $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn();
|
||||
}
|
||||
echo "OK seed-management-reviews — " . json_encode($counts, JSON_UNESCAPED_UNICODE) . "\n";
|
||||
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Calendario unico delle scadenze (Modulo C)
|
||||
* ----------------------------------------------------------------------------
|
||||
* AGGREGATORE IN SOLA LETTURA. Nessuna tabella propria, nessuna migrazione: le
|
||||
* scadenze vivono gia' nei moduli sorgente, ciascuna col proprio ciclo di vita.
|
||||
* Questo controller fa una UNION LIVE di TUTTE le sorgenti e normalizza ogni
|
||||
* evento in una forma unica:
|
||||
* { source, type, title, date (YYYY-MM-DD), status, severity, entity_type,
|
||||
* entity_id, link }
|
||||
* dove status e' calcolato LIVE confrontando la data con oggi (come review_schedule):
|
||||
* - done = scadenza gia' assolta/chiusa
|
||||
* - overdue = data < oggi e non assolta
|
||||
* - due_soon = data entro 14 giorni da oggi
|
||||
* - upcoming = data oltre 14 giorni
|
||||
*
|
||||
* Generalizza il pattern di DashboardController::deadlines() su TUTTE le sorgenti.
|
||||
* Ogni sorgente e' racchiusa in un try/catch DIFENSIVO: alcune tabelle (Modulo A
|
||||
* internal_audits, Modulo B management_reviews) potrebbero non esistere ancora, e
|
||||
* il calendario deve continuare a funzionare comunque (degrada solo quella fonte).
|
||||
*
|
||||
* Multi-tenancy: OGNI query filtra organization_id (anti-IDOR). Sola lettura →
|
||||
* requireOrgAccess(). NOTE: Database::query/fetchAll/fetchOne; jsonSuccess/Error exit.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
|
||||
class CalendarController extends BaseController
|
||||
{
|
||||
/** Soglia "in scadenza": eventi entro N giorni da oggi (inclusi) sono due_soon. */
|
||||
private const DUE_SOON_DAYS = 14;
|
||||
|
||||
/** Tipi noti (per la validazione del filtro ?types= e per la legenda frontend). */
|
||||
private const KNOWN_TYPES = [
|
||||
'incident_early_warning', 'incident_notification', 'incident_final_report',
|
||||
'policy_review', 'risk_treatment', 'control_review',
|
||||
'nc_target_close', 'capa_action', 'training_due',
|
||||
'stakeholder_activity', 'review_schedule',
|
||||
'internal_audit', 'management_review_decision',
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// GET /api/calendar/events?from=YYYY-MM-DD&to=YYYY-MM-DD&types=csv
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
public function events(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
[$from, $to] = $this->resolveRange();
|
||||
$typeFilter = $this->resolveTypeFilter();
|
||||
|
||||
$events = $this->collect($orgId, $from, $to);
|
||||
|
||||
// Filtro per tipo richiesto (applicato in PHP, gli eventi sono pochi per org).
|
||||
if ($typeFilter !== null) {
|
||||
$events = array_values(array_filter($events, static fn($e) => in_array($e['type'], $typeFilter, true)));
|
||||
}
|
||||
|
||||
// Ordina: overdue prima, poi per data crescente.
|
||||
usort($events, static function ($a, $b) {
|
||||
$oa = $a['status'] === 'overdue' ? 0 : 1;
|
||||
$ob = $b['status'] === 'overdue' ? 0 : 1;
|
||||
if ($oa !== $ob) { return $oa <=> $ob; }
|
||||
return strcmp($a['date'], $b['date']);
|
||||
});
|
||||
|
||||
$this->jsonSuccess([
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'today' => date('Y-m-d'),
|
||||
'due_soon_days'=> self::DUE_SOON_DAYS,
|
||||
'known_types' => self::KNOWN_TYPES,
|
||||
'count' => count($events),
|
||||
'events' => $events,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// GET /api/calendar/summary — conteggi per stato (e per tipo)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
public function summary(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
[$from, $to] = $this->resolveRange();
|
||||
$events = $this->collect($orgId, $from, $to);
|
||||
|
||||
$byStatus = ['overdue' => 0, 'due_soon' => 0, 'upcoming' => 0, 'done' => 0];
|
||||
$byType = [];
|
||||
foreach ($events as $e) {
|
||||
$byStatus[$e['status']] = ($byStatus[$e['status']] ?? 0) + 1;
|
||||
$byType[$e['type']] = ($byType[$e['type']] ?? 0) + 1;
|
||||
}
|
||||
// Aperte = tutte tranne done (utile come badge dashboard).
|
||||
$open = $byStatus['overdue'] + $byStatus['due_soon'] + $byStatus['upcoming'];
|
||||
|
||||
$this->jsonSuccess([
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'today' => date('Y-m-d'),
|
||||
'total' => count($events),
|
||||
'open' => $open,
|
||||
'by_status' => $byStatus,
|
||||
'by_type' => $byType,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// RACCOLTA — UNION di tutte le sorgenti, ognuna difensiva (try/catch)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Raccoglie e normalizza tutti gli eventi della finestra [from,to] per l'org.
|
||||
* Ogni sorgente che fallisce (tabella/colonna mancante) viene saltata senza
|
||||
* far cadere l'intero calendario.
|
||||
*/
|
||||
private function collect(int $orgId, string $from, string $to): array
|
||||
{
|
||||
$events = [];
|
||||
$sources = [
|
||||
'incidents' => fn() => $this->srcIncidents($orgId, $from, $to),
|
||||
'policies' => fn() => $this->srcPolicies($orgId, $from, $to),
|
||||
'risk_treatments' => fn() => $this->srcRiskTreatments($orgId, $from, $to),
|
||||
'controls' => fn() => $this->srcControls($orgId, $from, $to),
|
||||
'non_conformities' => fn() => $this->srcNonConformities($orgId, $from, $to),
|
||||
'capa_actions' => fn() => $this->srcCapaActions($orgId, $from, $to),
|
||||
'training' => fn() => $this->srcTraining($orgId, $from, $to),
|
||||
'stk_activities' => fn() => $this->srcStkActivities($orgId, $from, $to),
|
||||
'review_schedule' => fn() => $this->srcReviewSchedule($orgId, $from, $to),
|
||||
'internal_audits' => fn() => $this->srcInternalAudits($orgId, $from, $to),
|
||||
'mgmt_reviews' => fn() => $this->srcManagementReviewDecisions($orgId, $from, $to),
|
||||
];
|
||||
foreach ($sources as $rows) {
|
||||
try {
|
||||
foreach ($rows() as $ev) {
|
||||
if ($ev !== null) { $events[] = $ev; }
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Sorgente non disponibile (es. tabella di un modulo non ancora migrato):
|
||||
// si ignora senza compromettere le altre fonti.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return $events;
|
||||
}
|
||||
|
||||
/** Incidenti Art.23: early_warning / notification / final_report (datetime). */
|
||||
private function srcIncidents(int $orgId, string $from, string $to): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, title, severity,
|
||||
early_warning_due, early_warning_sent_at,
|
||||
notification_due, notification_sent_at,
|
||||
final_report_due, final_report_sent_at
|
||||
FROM incidents
|
||||
WHERE organization_id = ? AND is_significant = 1
|
||||
AND status NOT IN ("closed", "post_mortem")',
|
||||
[$orgId]
|
||||
);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
if ($r['early_warning_due'] && !$r['early_warning_sent_at']) {
|
||||
$out[] = $this->makeEvent('incidents', 'incident_early_warning',
|
||||
'Early Warning: ' . $r['title'], $r['early_warning_due'],
|
||||
'critical', 'incident', (int) $r['id'], '/incidents.html', $from, $to, false);
|
||||
}
|
||||
if ($r['notification_due'] && !$r['notification_sent_at']) {
|
||||
$out[] = $this->makeEvent('incidents', 'incident_notification',
|
||||
'Notifica CSIRT: ' . $r['title'], $r['notification_due'],
|
||||
'high', 'incident', (int) $r['id'], '/incidents.html', $from, $to, false);
|
||||
}
|
||||
if ($r['final_report_due'] && !$r['final_report_sent_at']) {
|
||||
$out[] = $this->makeEvent('incidents', 'incident_final_report',
|
||||
'Report finale: ' . $r['title'], $r['final_report_due'],
|
||||
'medium', 'incident', (int) $r['id'], '/incidents.html', $from, $to, false);
|
||||
}
|
||||
}
|
||||
return array_values(array_filter($out));
|
||||
}
|
||||
|
||||
/** Revisione policy/procedure: policies.next_review_date (status != archived). */
|
||||
private function srcPolicies(int $orgId, string $from, string $to): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, title, next_review_date
|
||||
FROM policies
|
||||
WHERE organization_id = ? AND next_review_date IS NOT NULL
|
||||
AND status NOT IN ("archived")',
|
||||
[$orgId]
|
||||
);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$out[] = $this->makeEvent('policies', 'policy_review',
|
||||
'Revisione policy: ' . $r['title'], $r['next_review_date'],
|
||||
'medium', 'policy', (int) $r['id'], '/policies.html', $from, $to, false);
|
||||
}
|
||||
return array_values(array_filter($out));
|
||||
}
|
||||
|
||||
/** Trattamenti rischio: risk_treatments.due_date JOIN risks (org via risks). */
|
||||
private function srcRiskTreatments(int $orgId, string $from, string $to): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT rt.id, rt.due_date, r.title AS risk_title
|
||||
FROM risk_treatments rt
|
||||
JOIN risks r ON r.id = rt.risk_id
|
||||
WHERE r.organization_id = ? AND rt.status IN ("planned", "in_progress")
|
||||
AND rt.due_date IS NOT NULL',
|
||||
[$orgId]
|
||||
);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$out[] = $this->makeEvent('risk_treatments', 'risk_treatment',
|
||||
'Trattamento rischio: ' . $r['risk_title'], $r['due_date'],
|
||||
'medium', 'risk_treatment', (int) $r['id'], '/risks.html', $from, $to, false);
|
||||
}
|
||||
return array_values(array_filter($out));
|
||||
}
|
||||
|
||||
/** Revisione controlli: compliance_controls.next_review_date (non verificati/in corso). */
|
||||
private function srcControls(int $orgId, string $from, string $to): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, control_code, title, next_review_date, status
|
||||
FROM compliance_controls
|
||||
WHERE organization_id = ? AND next_review_date IS NOT NULL',
|
||||
[$orgId]
|
||||
);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$title = trim((string) $r['control_code'] . ' ' . (string) $r['title']);
|
||||
$out[] = $this->makeEvent('compliance_controls', 'control_review',
|
||||
'Revisione controllo: ' . $title, $r['next_review_date'],
|
||||
'medium', 'compliance_control', (int) $r['id'], '/reports.html', $from, $to, false);
|
||||
}
|
||||
return array_values(array_filter($out));
|
||||
}
|
||||
|
||||
/** Non conformita': non_conformities.target_close_date (status non chiuso). */
|
||||
private function srcNonConformities(int $orgId, string $from, string $to): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, ncr_code, title, target_close_date
|
||||
FROM non_conformities
|
||||
WHERE organization_id = ? AND target_close_date IS NOT NULL
|
||||
AND status NOT IN ("closed", "cancelled")',
|
||||
[$orgId]
|
||||
);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$title = trim((string) $r['ncr_code'] . ' ' . (string) $r['title']);
|
||||
$out[] = $this->makeEvent('non_conformities', 'nc_target_close',
|
||||
'Chiusura NC: ' . $title, $r['target_close_date'],
|
||||
'high', 'non_conformity', (int) $r['id'], '/reports.html', $from, $to, false);
|
||||
}
|
||||
return array_values(array_filter($out));
|
||||
}
|
||||
|
||||
/** Azioni correttive: capa_actions.due_date (status non completed/verified). */
|
||||
private function srcCapaActions(int $orgId, string $from, string $to): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, capa_code, title, due_date
|
||||
FROM capa_actions
|
||||
WHERE organization_id = ? AND due_date IS NOT NULL
|
||||
AND status NOT IN ("completed", "verified")',
|
||||
[$orgId]
|
||||
);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$title = trim((string) $r['capa_code'] . ' ' . (string) $r['title']);
|
||||
$out[] = $this->makeEvent('capa_actions', 'capa_action',
|
||||
'Azione correttiva: ' . $title, $r['due_date'],
|
||||
'medium', 'capa_action', (int) $r['id'], '/reports.html', $from, $to, false);
|
||||
}
|
||||
return array_values(array_filter($out));
|
||||
}
|
||||
|
||||
/** Formazione: training_assignments.due_date (status assigned/in_progress). */
|
||||
private function srcTraining(int $orgId, string $from, string $to): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT ta.id, ta.due_date, tc.title, u.full_name
|
||||
FROM training_assignments ta
|
||||
JOIN training_courses tc ON tc.id = ta.course_id
|
||||
LEFT JOIN users u ON u.id = ta.user_id
|
||||
WHERE ta.organization_id = ? AND ta.status IN ("assigned", "in_progress")
|
||||
AND ta.due_date IS NOT NULL',
|
||||
[$orgId]
|
||||
);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$who = $r['full_name'] ? (' - ' . $r['full_name']) : '';
|
||||
$out[] = $this->makeEvent('training', 'training_due',
|
||||
'Formazione: ' . $r['title'] . $who, $r['due_date'],
|
||||
'low', 'training', (int) $r['id'], '/training.html', $from, $to, false);
|
||||
}
|
||||
return array_values(array_filter($out));
|
||||
}
|
||||
|
||||
/**
|
||||
* Attivita' stakeholder (C5.2): stk_activities.due_date (preferita) e, se assente,
|
||||
* planned_date. Esclude annullate/completate. due_date "assolta" se completed.
|
||||
*/
|
||||
private function srcStkActivities(int $orgId, string $from, string $to): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, title, type, planned_date, due_date, status
|
||||
FROM stk_activities
|
||||
WHERE organization_id = ? AND status NOT IN ("cancelled")',
|
||||
[$orgId]
|
||||
);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$done = ($r['status'] === 'completed');
|
||||
$date = $r['due_date'] ?: $r['planned_date'];
|
||||
if (!$date) { continue; }
|
||||
$label = ($r['due_date'] ? 'Scadenza attivita': 'Attivita pianificata') . ': ' . $r['title'];
|
||||
$out[] = $this->makeEvent('stk_activities', 'stakeholder_activity',
|
||||
$label, $date, 'medium', 'stk_activity', (int) $r['id'],
|
||||
'/stakeholder-activities.html', $from, $to, $done);
|
||||
}
|
||||
return array_values(array_filter($out));
|
||||
}
|
||||
|
||||
/** Scadenziario revisioni periodiche (A4 4.4): review_schedule.next_review_date. */
|
||||
private function srcReviewSchedule(int $orgId, string $from, string $to): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, title, entity_type, next_review_date, last_reviewed_at
|
||||
FROM review_schedule
|
||||
WHERE organization_id = ? AND next_review_date IS NOT NULL',
|
||||
[$orgId]
|
||||
);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$out[] = $this->makeEvent('review_schedule', 'review_schedule',
|
||||
$r['title'], $r['next_review_date'],
|
||||
'medium', 'review_schedule', (int) $r['id'], '/review-schedule.html', $from, $to, false);
|
||||
}
|
||||
return array_values(array_filter($out));
|
||||
}
|
||||
|
||||
/** Audit interni (Modulo A, opzionale): internal_audits.planned_date. */
|
||||
private function srcInternalAudits(int $orgId, string $from, string $to): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, code, title, planned_date, status
|
||||
FROM internal_audits
|
||||
WHERE organization_id = ? AND planned_date IS NOT NULL',
|
||||
[$orgId]
|
||||
);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$done = in_array($r['status'], ['completed', 'cancelled'], true);
|
||||
$title = trim((string) $r['code'] . ' ' . (string) $r['title']);
|
||||
$out[] = $this->makeEvent('internal_audits', 'internal_audit',
|
||||
'Audit interno: ' . $title, $r['planned_date'],
|
||||
'medium', 'internal_audit', (int) $r['id'], '/internal-audits.html', $from, $to, $done);
|
||||
}
|
||||
return array_values(array_filter($out));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decisioni del riesame di direzione (Modulo B, opzionale):
|
||||
* management_review_decisions.due_date JOIN management_reviews per filtrare per org.
|
||||
*/
|
||||
private function srcManagementReviewDecisions(int $orgId, string $from, string $to): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT d.id, d.decision, d.due_date, d.status, mr.code AS review_code
|
||||
FROM management_review_decisions d
|
||||
JOIN management_reviews mr ON mr.id = d.review_id
|
||||
WHERE mr.organization_id = ? AND d.due_date IS NOT NULL',
|
||||
[$orgId]
|
||||
);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$done = ($r['status'] === 'done');
|
||||
$txt = mb_substr((string) $r['decision'], 0, 120);
|
||||
$out[] = $this->makeEvent('management_reviews', 'management_review_decision',
|
||||
'Decisione riesame: ' . $txt, $r['due_date'],
|
||||
'medium', 'management_review_decision', (int) $r['id'],
|
||||
'/management-review.html', $from, $to, $done);
|
||||
}
|
||||
return array_values(array_filter($out));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// HELPER
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalizza una riga in evento di calendario. Ritorna null se la data
|
||||
* (normalizzata a Y-m-d) cade fuori dalla finestra [from,to]. $done forza
|
||||
* lo status a 'done' (scadenza gia' assolta/chiusa), altrimenti lo calcola
|
||||
* LIVE rispetto a oggi.
|
||||
*/
|
||||
private function makeEvent(
|
||||
string $source, string $type, string $title, ?string $rawDate,
|
||||
string $severity, string $entityType, int $entityId, string $link,
|
||||
string $from, string $to, bool $done
|
||||
): ?array {
|
||||
if (!$rawDate) { return null; }
|
||||
$date = substr((string) $rawDate, 0, 10); // DATETIME o DATE → Y-m-d
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { return null; }
|
||||
if ($date < $from || $date > $to) { return null; }
|
||||
|
||||
return [
|
||||
'source' => $source,
|
||||
'type' => $type,
|
||||
'title' => mb_substr($title, 0, 255),
|
||||
'date' => $date,
|
||||
'status' => $this->computeStatus($date, $done),
|
||||
'severity' => $severity,
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'link' => $link,
|
||||
];
|
||||
}
|
||||
|
||||
/** Stato LIVE: done | overdue | due_soon (<=N gg) | upcoming. */
|
||||
private function computeStatus(string $date, bool $done): string
|
||||
{
|
||||
if ($done) { return 'done'; }
|
||||
$today = new DateTimeImmutable('today');
|
||||
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
||||
if (!$d) { return 'upcoming'; }
|
||||
$diffDays = (int) $today->diff($d)->format('%r%a');
|
||||
if ($diffDays < 0) { return 'overdue'; }
|
||||
if ($diffDays <= self::DUE_SOON_DAYS) { return 'due_soon'; }
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
/**
|
||||
* Risolve la finestra [from,to]. Default ampio: dal 1° giorno di 1 mese fa al
|
||||
* 1° giorno di 13 mesi avanti (copre tutto cio' che ha senso vedere in un
|
||||
* calendario annuale). Param from/to opzionali sovrascrivono (validati Y-m-d).
|
||||
*/
|
||||
private function resolveRange(): array
|
||||
{
|
||||
$from = $this->validDate($this->getParam('from'));
|
||||
$to = $this->validDate($this->getParam('to'));
|
||||
if (!$from) { $from = (new DateTimeImmutable('first day of this month'))->modify('-1 month')->format('Y-m-d'); }
|
||||
if (!$to) { $to = (new DateTimeImmutable('first day of this month'))->modify('+13 months')->format('Y-m-d'); }
|
||||
if ($from > $to) { [$from, $to] = [$to, $from]; }
|
||||
return [$from, $to];
|
||||
}
|
||||
|
||||
/** Filtro tipi: ?types=a,b,c → solo i tipi noti; null = nessun filtro. */
|
||||
private function resolveTypeFilter(): ?array
|
||||
{
|
||||
$raw = $this->getParam('types');
|
||||
if ($raw === null || $raw === '') { return null; }
|
||||
$parts = array_filter(array_map('trim', explode(',', (string) $raw)), static fn($t) => $t !== '');
|
||||
$valid = array_values(array_intersect($parts, self::KNOWN_TYPES));
|
||||
return $valid ?: null;
|
||||
}
|
||||
|
||||
/** Valida una data Y-m-d; ritorna la stringa normalizzata o null. */
|
||||
private function validDate($v): ?string
|
||||
{
|
||||
if ($v === null || $v === '') { return null; }
|
||||
$s = trim((string) $v);
|
||||
$dt = DateTime::createFromFormat('Y-m-d', $s);
|
||||
return ($dt && $dt->format('Y-m-d') === $s) ? $s : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Audit interni (ISO 27001 §9.2) — MODULO A
|
||||
* ----------------------------------------------------------------------------
|
||||
* Ciclo audit interno: programma -> checklist di conduzione -> esiti -> finding NC.
|
||||
* - PROGRAMMA: internal_audits (code AUD-NNN progressivo per org, scope, criteri,
|
||||
* auditor capo come utente o ruolo organigramma, date pianificate/eseguite,
|
||||
* stato, conclusione). La data pianificata alimenta il calendario (review_schedule).
|
||||
* - CHECKLIST: internal_audit_items pre-popolata al create con le clausole 4-10
|
||||
* ISO 27001 (lista statica) + i controlli Annex A applicabili dal SoA dell'org
|
||||
* (isms_soa.applicable=1, query difensiva: la tabella potrebbe non esistere).
|
||||
* - FINDING: una voce non_conforme può generare una NC (non_conformities,
|
||||
* source='audit', source_entity_type='internal_audit_item'), che confluisce
|
||||
* nel modulo NCR/CAPA esistente.
|
||||
* - EVIDENZE: su evidence_files (entity_type='internal_audit') — gestite altrove.
|
||||
* - REPORT: HTML stampabile (no PDF lib).
|
||||
*
|
||||
* Multi-tenancy: ogni query filtra organization_id. Anti-IDOR su audit/item/role
|
||||
* (verificati appartenenti all'org). logAudit su create/update/delete.
|
||||
* NOTE: Database::query/fetchAll/fetchOne/insert/update/delete; jsonSuccess/Error exit.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
|
||||
class InternalAuditController extends BaseController
|
||||
{
|
||||
private const MANAGE_ROLES = ['org_admin', 'compliance_manager', 'auditor'];
|
||||
|
||||
/**
|
||||
* Clausole 4-10 ISO/IEC 27001:2022 — checklist statica (ref_type='clause').
|
||||
* Pre-popolata al create di ogni audit; l'auditor parte già con la checklist.
|
||||
*/
|
||||
private const ISO_CLAUSES = [
|
||||
['4.1', 'Comprensione dell\'organizzazione e del suo contesto'],
|
||||
['4.2', 'Comprensione delle esigenze e aspettative delle parti interessate'],
|
||||
['4.3', 'Determinazione dello scopo del SGSI'],
|
||||
['4.4', 'Sistema di gestione per la sicurezza delle informazioni'],
|
||||
['5.1', 'Leadership e impegno della direzione'],
|
||||
['5.2', 'Politica per la sicurezza delle informazioni'],
|
||||
['5.3', 'Ruoli, responsabilità e autorità organizzative'],
|
||||
['6.1.1', 'Azioni per affrontare rischi e opportunità — generalità'],
|
||||
['6.1.2', 'Valutazione del rischio per la sicurezza delle informazioni'],
|
||||
['6.1.3', 'Trattamento del rischio per la sicurezza delle informazioni (SoA)'],
|
||||
['6.2', 'Obiettivi di sicurezza delle informazioni e pianificazione'],
|
||||
['6.3', 'Pianificazione delle modifiche'],
|
||||
['7.1', 'Risorse'],
|
||||
['7.2', 'Competenza'],
|
||||
['7.3', 'Consapevolezza'],
|
||||
['7.4', 'Comunicazione'],
|
||||
['7.5', 'Informazioni documentate'],
|
||||
['8.1', 'Pianificazione e controllo operativi'],
|
||||
['8.2', 'Valutazione del rischio per la sicurezza delle informazioni'],
|
||||
['8.3', 'Trattamento del rischio per la sicurezza delle informazioni'],
|
||||
['9.1', 'Monitoraggio, misurazione, analisi e valutazione'],
|
||||
['9.2', 'Audit interno'],
|
||||
['9.3', 'Riesame di direzione'],
|
||||
['10.1', 'Miglioramento continuo'],
|
||||
['10.2', 'Non conformità e azioni correttive'],
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// PROGRAMMA AUDIT
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/internal-audits/list */
|
||||
public function list(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT a.id, a.code, a.title, a.status, a.planned_date, a.executed_date,
|
||||
a.lead_auditor_user_id, u.full_name AS lead_auditor_name,
|
||||
a.lead_auditor_role_id, r.role_name AS lead_auditor_role_name, a.updated_at,
|
||||
(SELECT COUNT(*) FROM internal_audit_items i WHERE i.audit_id = a.id) AS n_items,
|
||||
(SELECT COUNT(*) FROM internal_audit_items i WHERE i.audit_id = a.id AND i.result = \'non_conforme\') AS n_nc
|
||||
FROM internal_audits a
|
||||
LEFT JOIN users u ON u.id = a.lead_auditor_user_id
|
||||
LEFT JOIN org_roles r ON r.id = a.lead_auditor_role_id
|
||||
WHERE a.organization_id = ?
|
||||
ORDER BY (a.planned_date IS NULL), a.planned_date DESC, a.id DESC',
|
||||
[$orgId]
|
||||
);
|
||||
$out = array_map(static fn($a) => [
|
||||
'id' => (int) $a['id'], 'code' => $a['code'], 'title' => $a['title'], 'status' => $a['status'],
|
||||
'planned_date' => $a['planned_date'], 'executed_date' => $a['executed_date'],
|
||||
'lead_auditor_user_id' => $a['lead_auditor_user_id'] !== null ? (int) $a['lead_auditor_user_id'] : null,
|
||||
'lead_auditor_name' => $a['lead_auditor_name'],
|
||||
'lead_auditor_role_id' => $a['lead_auditor_role_id'] !== null ? (int) $a['lead_auditor_role_id'] : null,
|
||||
'lead_auditor_role_name' => $a['lead_auditor_role_name'],
|
||||
'n_items' => (int) $a['n_items'], 'n_nc' => (int) $a['n_nc'], 'updated_at' => $a['updated_at'],
|
||||
], $rows);
|
||||
$this->jsonSuccess(['audits' => $out]);
|
||||
}
|
||||
|
||||
/** GET /api/internal-audits/{id} (con items) */
|
||||
public function get(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$a = Database::fetchOne(
|
||||
'SELECT a.*, u.full_name AS lead_auditor_name, r.role_name AS lead_auditor_role_name
|
||||
FROM internal_audits a
|
||||
LEFT JOIN users u ON u.id = a.lead_auditor_user_id
|
||||
LEFT JOIN org_roles r ON r.id = a.lead_auditor_role_id
|
||||
WHERE a.id = ? AND a.organization_id = ?',
|
||||
[$id, $orgId]
|
||||
);
|
||||
if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); }
|
||||
$this->jsonSuccess([
|
||||
'id' => (int) $a['id'], 'code' => $a['code'], 'title' => $a['title'],
|
||||
'scope' => $a['scope'], 'criteria' => $a['criteria'],
|
||||
'planned_date' => $a['planned_date'], 'executed_date' => $a['executed_date'],
|
||||
'status' => $a['status'],
|
||||
'lead_auditor_user_id' => $a['lead_auditor_user_id'] !== null ? (int) $a['lead_auditor_user_id'] : null,
|
||||
'lead_auditor_name' => $a['lead_auditor_name'],
|
||||
'lead_auditor_role_id' => $a['lead_auditor_role_id'] !== null ? (int) $a['lead_auditor_role_id'] : null,
|
||||
'lead_auditor_role_name' => $a['lead_auditor_role_name'],
|
||||
'conclusion' => $a['conclusion'], 'updated_at' => $a['updated_at'],
|
||||
'items' => $this->loadItems($id),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/internal-audits/create
|
||||
* Genera code AUD-NNN progressivo per org e PRE-POPOLA la checklist:
|
||||
* - clausole 4-10 ISO 27001 (statiche),
|
||||
* - controlli Annex A applicabili dal SoA dell'org (se isms_soa esiste).
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$b = $this->getJsonBody();
|
||||
|
||||
$title = trim((string) ($b['title'] ?? ''));
|
||||
if ($title === '' || mb_strlen($title) > 255) { $this->jsonError('Titolo obbligatorio (max 255)', 422, 'INVALID_TITLE'); }
|
||||
$status = in_array($b['status'] ?? '', ['planned', 'in_progress', 'completed', 'cancelled'], true) ? $b['status'] : 'planned';
|
||||
$planned = $this->validateDate($b['planned_date'] ?? null, 'planned_date');
|
||||
$executed = $this->validateDate($b['executed_date'] ?? null, 'executed_date');
|
||||
$leadUserId = $this->validateUser($b['lead_auditor_user_id'] ?? null, $orgId);
|
||||
$leadRoleId = $this->validateRole($b['lead_auditor_role_id'] ?? null, $orgId);
|
||||
|
||||
$code = $this->generateAuditCode($orgId);
|
||||
$id = (int) Database::insert('internal_audits', [
|
||||
'organization_id' => $orgId,
|
||||
'code' => $code,
|
||||
'title' => $title,
|
||||
'scope' => $this->nullableStr($b['scope'] ?? null),
|
||||
'criteria' => $this->nullableStr($b['criteria'] ?? null),
|
||||
'planned_date' => $planned,
|
||||
'executed_date' => $executed,
|
||||
'status' => $status,
|
||||
'lead_auditor_user_id' => $leadUserId,
|
||||
'lead_auditor_role_id' => $leadRoleId,
|
||||
'conclusion' => $this->nullableStr($b['conclusion'] ?? null),
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
|
||||
$seeded = $this->seedChecklist($id, $orgId);
|
||||
$this->upsertCalendar($id, $orgId, $code, $title, $planned);
|
||||
$this->logAudit('internal_audit_created', 'internal_audit', $id, ['code' => $code, 'title' => $title, 'items' => $seeded]);
|
||||
$this->jsonSuccess(['id' => $id, 'code' => $code, 'items_seeded' => $seeded], 'Audit creato', 201);
|
||||
}
|
||||
|
||||
/** PUT /api/internal-audits/{id} */
|
||||
public function update(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$b = $this->getJsonBody();
|
||||
|
||||
$a = Database::fetchOne('SELECT id, code, title, planned_date FROM internal_audits WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); }
|
||||
|
||||
$updates = [];
|
||||
if ($this->hasParam('title')) {
|
||||
$title = trim((string) ($b['title'] ?? ''));
|
||||
if ($title === '' || mb_strlen($title) > 255) { $this->jsonError('Titolo non valido (max 255)', 422, 'INVALID_TITLE'); }
|
||||
$updates['title'] = $title;
|
||||
}
|
||||
if ($this->hasParam('scope')) { $updates['scope'] = $this->nullableStr($b['scope'] ?? null); }
|
||||
if ($this->hasParam('criteria')) { $updates['criteria'] = $this->nullableStr($b['criteria'] ?? null); }
|
||||
if ($this->hasParam('planned_date')) { $updates['planned_date'] = $this->validateDate($b['planned_date'] ?? null, 'planned_date'); }
|
||||
if ($this->hasParam('executed_date')) { $updates['executed_date'] = $this->validateDate($b['executed_date'] ?? null, 'executed_date'); }
|
||||
if ($this->hasParam('status') && in_array($b['status'], ['planned', 'in_progress', 'completed', 'cancelled'], true)) { $updates['status'] = $b['status']; }
|
||||
if ($this->hasParam('conclusion')) { $updates['conclusion'] = $this->nullableStr($b['conclusion'] ?? null); }
|
||||
if ($this->hasParam('lead_auditor_user_id')) { $updates['lead_auditor_user_id'] = $this->validateUser($b['lead_auditor_user_id'] ?? null, $orgId); }
|
||||
if ($this->hasParam('lead_auditor_role_id')) { $updates['lead_auditor_role_id'] = $this->validateRole($b['lead_auditor_role_id'] ?? null, $orgId); }
|
||||
|
||||
if (!empty($updates)) {
|
||||
Database::update('internal_audits', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
}
|
||||
// riallinea il calendario
|
||||
$title = $updates['title'] ?? $a['title'];
|
||||
$planned = array_key_exists('planned_date', $updates) ? $updates['planned_date'] : $a['planned_date'];
|
||||
$this->upsertCalendar($id, $orgId, $a['code'], $title, $planned);
|
||||
|
||||
$this->logAudit('internal_audit_updated', 'internal_audit', $id, array_keys($updates));
|
||||
$this->jsonSuccess(['id' => $id, 'updated' => array_keys($updates)], 'Audit aggiornato');
|
||||
}
|
||||
|
||||
/** DELETE /api/internal-audits/{id} (org_admin) */
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin']);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$a = Database::fetchOne('SELECT id FROM internal_audits WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); }
|
||||
Database::delete('review_schedule', 'organization_id = ? AND entity_type = ? AND entity_id = ?', [$orgId, 'internal_audit', $id]);
|
||||
Database::delete('internal_audits', 'id = ? AND organization_id = ?', [$id, $orgId]); // items in cascata
|
||||
$this->logAudit('internal_audit_deleted', 'internal_audit', $id);
|
||||
$this->jsonSuccess(null, 'Audit eliminato');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// VOCI DI CHECKLIST
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* PUT /api/internal-audits/items/{subId} Body: {result?, note?}
|
||||
* Aggiorna esito/note di una voce, verificando che appartenga a un audit dell'org.
|
||||
*/
|
||||
public function updateItem(int $itemId): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$item = $this->assertItem($itemId, $orgId);
|
||||
$b = $this->getJsonBody();
|
||||
|
||||
$updates = [];
|
||||
if ($this->hasParam('result')) {
|
||||
$allowed = ['da_verificare', 'conforme', 'non_conforme', 'osservazione', 'opportunita', 'non_applicabile'];
|
||||
if (!in_array($b['result'] ?? '', $allowed, true)) { $this->jsonError('Esito non valido', 422, 'INVALID_RESULT'); }
|
||||
$updates['result'] = $b['result'];
|
||||
}
|
||||
if ($this->hasParam('note')) { $updates['note'] = $this->nullableStr($b['note'] ?? null); }
|
||||
if ($this->hasParam('checkpoint')) {
|
||||
$cp = trim((string) ($b['checkpoint'] ?? ''));
|
||||
if ($cp === '') { $this->jsonError('Checkpoint non valido', 422, 'INVALID_CHECKPOINT'); }
|
||||
$updates['checkpoint'] = mb_substr($cp, 0, 2000);
|
||||
}
|
||||
if (empty($updates)) { $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES'); }
|
||||
|
||||
Database::update('internal_audit_items', $updates, 'id = ?', [$itemId]);
|
||||
$this->logAudit('internal_audit_item_updated', 'internal_audit_item', $itemId, ['audit_id' => (int) $item['audit_id']] + array_fill_keys(array_keys($updates), 1));
|
||||
$this->jsonSuccess(['id' => $itemId, 'updated' => array_keys($updates)], 'Voce aggiornata');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/internal-audits/items Body: {audit_id*, checkpoint*, ref_type?, ref_code?, note?, result?}
|
||||
* Aggiunge una voce custom alla checklist (l'audit deve appartenere all'org).
|
||||
*/
|
||||
public function addItem(): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$b = $this->getJsonBody();
|
||||
|
||||
$auditId = (int) ($b['audit_id'] ?? 0);
|
||||
$this->assertAudit($auditId, $orgId);
|
||||
|
||||
$checkpoint = trim((string) ($b['checkpoint'] ?? ''));
|
||||
if ($checkpoint === '') { $this->jsonError('Checkpoint obbligatorio', 422, 'INVALID_CHECKPOINT'); }
|
||||
$refType = in_array($b['ref_type'] ?? '', ['clause', 'annex_control', 'nis2_measure', 'custom'], true) ? $b['ref_type'] : 'custom';
|
||||
$result = in_array($b['result'] ?? '', ['da_verificare', 'conforme', 'non_conforme', 'osservazione', 'opportunita', 'non_applicabile'], true) ? $b['result'] : 'da_verificare';
|
||||
|
||||
$maxOrd = (int) (Database::fetchOne('SELECT COALESCE(MAX(ord), 0) AS m FROM internal_audit_items WHERE audit_id = ?', [$auditId])['m'] ?? 0);
|
||||
$id = (int) Database::insert('internal_audit_items', [
|
||||
'audit_id' => $auditId,
|
||||
'ref_type' => $refType,
|
||||
'ref_code' => $this->nullableStr($b['ref_code'] ?? null, 32),
|
||||
'checkpoint' => mb_substr($checkpoint, 0, 2000),
|
||||
'result' => $result,
|
||||
'note' => $this->nullableStr($b['note'] ?? null),
|
||||
'ord' => $maxOrd + 1,
|
||||
]);
|
||||
$this->logAudit('internal_audit_item_added', 'internal_audit_item', $id, ['audit_id' => $auditId]);
|
||||
$this->jsonSuccess(['id' => $id], 'Voce aggiunta', 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// FINDING -> NON CONFORMITA'
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/internal-audits/{id}/raiseNcr Body: {item_id?}
|
||||
* Crea una NC (non_conformities) da una voce non conforme dell'audit.
|
||||
* source='audit' (l'ENUM 004 NON ha 'internal_audit'),
|
||||
* source_entity_type='internal_audit_item', source_entity_id=item_id.
|
||||
* Idempotente: se esiste già una NC per quella voce la restituisce.
|
||||
*/
|
||||
public function raiseNcr(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$audit = Database::fetchOne('SELECT id, code FROM internal_audits WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$audit) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); }
|
||||
$b = $this->getJsonBody();
|
||||
|
||||
$itemId = (int) ($b['item_id'] ?? 0);
|
||||
if ($itemId <= 0) { $this->jsonError('item_id obbligatorio', 422, 'MISSING_ITEM'); }
|
||||
$item = Database::fetchOne('SELECT id, audit_id, ref_type, ref_code, checkpoint FROM internal_audit_items WHERE id = ? AND audit_id = ?', [$itemId, $id]);
|
||||
if (!$item) { $this->jsonError('Voce non trovata in questo audit', 404, 'ITEM_NOT_FOUND'); }
|
||||
|
||||
// Idempotenza: una sola NC per voce
|
||||
$existing = Database::fetchOne(
|
||||
"SELECT id, ncr_code FROM non_conformities
|
||||
WHERE organization_id = ? AND source = 'audit' AND source_entity_type = 'internal_audit_item' AND source_entity_id = ?",
|
||||
[$orgId, $itemId]
|
||||
);
|
||||
if ($existing) {
|
||||
$this->jsonSuccess(['id' => (int) $existing['id'], 'ncr_code' => $existing['ncr_code'], 'already' => true], 'Non conformità già aperta per questa voce');
|
||||
}
|
||||
|
||||
$checkpoint = (string) $item['checkpoint'];
|
||||
$refCode = $item['ref_code'] ? '[' . $item['ref_code'] . '] ' : '';
|
||||
$titleBase = $refCode . $checkpoint;
|
||||
$title = mb_strlen($titleBase) > 200 ? mb_substr($titleBase, 0, 197) . '...' : $titleBase;
|
||||
if ($title === '') { $title = 'Non conformità da audit interno ' . $audit['code']; }
|
||||
|
||||
$ncrCode = $this->generateCode('NCR');
|
||||
$ncrId = (int) Database::insert('non_conformities', [
|
||||
'organization_id' => $orgId,
|
||||
'ncr_code' => $ncrCode,
|
||||
'title' => $title,
|
||||
'description' => "Rilevata nell'audit interno {$audit['code']}" . ($item['ref_code'] ? " (rif. {$item['ref_code']})" : '') . ": {$checkpoint}",
|
||||
'source' => 'audit',
|
||||
'source_entity_type' => 'internal_audit_item',
|
||||
'source_entity_id' => $itemId,
|
||||
'severity' => 'minor',
|
||||
'status' => 'open',
|
||||
'identified_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
$this->logAudit('internal_audit_ncr_raised', 'non_conformity', $ncrId, ['ncr_code' => $ncrCode, 'audit_id' => $id, 'item_id' => $itemId]);
|
||||
$this->jsonSuccess(['id' => $ncrId, 'ncr_code' => $ncrCode, 'already' => false], 'Non conformità creata', 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// REPORT
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/internal-audits/{id}/report — HTML stampabile (no PDF lib). */
|
||||
public function report(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor', 'board_member']);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$a = Database::fetchOne(
|
||||
'SELECT a.*, u.full_name AS lead_auditor_name, r.role_name AS lead_auditor_role_name, o.name AS org_name
|
||||
FROM internal_audits a
|
||||
LEFT JOIN users u ON u.id = a.lead_auditor_user_id
|
||||
LEFT JOIN org_roles r ON r.id = a.lead_auditor_role_id
|
||||
LEFT JOIN organizations o ON o.id = a.organization_id
|
||||
WHERE a.id = ? AND a.organization_id = ?',
|
||||
[$id, $orgId]
|
||||
);
|
||||
if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); }
|
||||
$items = $this->loadItems($id);
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo $this->renderReport($a, $items);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// HELPER
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function loadItems(int $auditId): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, ref_type, ref_code, checkpoint, result, note, ord
|
||||
FROM internal_audit_items WHERE audit_id = ?
|
||||
ORDER BY FIELD(ref_type, \'clause\',\'annex_control\',\'nis2_measure\',\'custom\'), ord ASC, id ASC',
|
||||
[$auditId]
|
||||
);
|
||||
// mappa item -> eventuale NC già aperta (per il pulsante "apri NC" del frontend)
|
||||
$ncByItem = [];
|
||||
$ncs = Database::fetchAll(
|
||||
"SELECT source_entity_id, id, ncr_code FROM non_conformities
|
||||
WHERE source = 'audit' AND source_entity_type = 'internal_audit_item'
|
||||
AND source_entity_id IN (SELECT id FROM internal_audit_items WHERE audit_id = ?)",
|
||||
[$auditId]
|
||||
);
|
||||
foreach ($ncs as $n) { $ncByItem[(int) $n['source_entity_id']] = ['id' => (int) $n['id'], 'ncr_code' => $n['ncr_code']]; }
|
||||
|
||||
return array_map(static function ($r) use ($ncByItem) {
|
||||
$iid = (int) $r['id'];
|
||||
return [
|
||||
'id' => $iid, 'ref_type' => $r['ref_type'], 'ref_code' => $r['ref_code'],
|
||||
'checkpoint' => $r['checkpoint'], 'result' => $r['result'], 'note' => $r['note'],
|
||||
'ord' => (int) $r['ord'],
|
||||
'ncr' => $ncByItem[$iid] ?? null,
|
||||
];
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
/** Genera code AUD-NNN progressivo per org (es. AUD-001). */
|
||||
private function generateAuditCode(int $orgId): string
|
||||
{
|
||||
$n = (int) (Database::fetchOne(
|
||||
"SELECT COUNT(*) AS c FROM internal_audits WHERE organization_id = ?",
|
||||
[$orgId]
|
||||
)['c'] ?? 0);
|
||||
// evita collisione su uno UNIQUE eventuale futuro: incrementa finché libero
|
||||
for ($i = $n + 1; $i < $n + 1000; $i++) {
|
||||
$code = 'AUD-' . str_pad((string) $i, 3, '0', STR_PAD_LEFT);
|
||||
$exists = Database::fetchOne('SELECT id FROM internal_audits WHERE organization_id = ? AND code = ?', [$orgId, $code]);
|
||||
if (!$exists) { return $code; }
|
||||
}
|
||||
return 'AUD-' . str_pad((string) ($n + 1), 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-popola la checklist: clausole 4-10 (statiche) + Annex A applicabili da SoA.
|
||||
* Ritorna il numero di voci create.
|
||||
*/
|
||||
private function seedChecklist(int $auditId, int $orgId): int
|
||||
{
|
||||
$ord = 0;
|
||||
$count = 0;
|
||||
foreach (self::ISO_CLAUSES as [$code, $checkpoint]) {
|
||||
Database::insert('internal_audit_items', [
|
||||
'audit_id' => $auditId,
|
||||
'ref_type' => 'clause',
|
||||
'ref_code' => $code,
|
||||
'checkpoint' => $checkpoint,
|
||||
'result' => 'da_verificare',
|
||||
'ord' => $ord++,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
// Annex A dal SoA dell'org (query difensiva: isms_soa potrebbe non esistere)
|
||||
try {
|
||||
$soa = Database::fetchAll(
|
||||
"SELECT control_code, source_ref FROM isms_soa
|
||||
WHERE organization_id = ? AND applicable = 1
|
||||
ORDER BY control_code ASC",
|
||||
[$orgId]
|
||||
);
|
||||
$ord = 0;
|
||||
foreach ($soa as $s) {
|
||||
$cp = $s['source_ref'] ? (string) $s['source_ref'] : ('Controllo applicabile (SoA): ' . $s['control_code']);
|
||||
Database::insert('internal_audit_items', [
|
||||
'audit_id' => $auditId,
|
||||
'ref_type' => 'annex_control',
|
||||
'ref_code' => mb_substr((string) $s['control_code'], 0, 32),
|
||||
'checkpoint' => mb_substr($cp, 0, 2000),
|
||||
'result' => 'da_verificare',
|
||||
'ord' => $ord++,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
// tabella SoA assente o non popolata per l'org: la checklist resta con le sole clausole
|
||||
error_log('[InternalAudit] SoA seed skipped: ' . $e->getMessage());
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/** Crea/aggiorna/elimina la riga di calendario (review_schedule) per l'audit. */
|
||||
private function upsertCalendar(int $auditId, int $orgId, ?string $code, string $title, ?string $plannedDate): void
|
||||
{
|
||||
if ($plannedDate === null) {
|
||||
Database::delete('review_schedule', 'organization_id = ? AND entity_type = ? AND entity_id = ?', [$orgId, 'internal_audit', $auditId]);
|
||||
return;
|
||||
}
|
||||
$label = mb_substr('Audit interno ' . ($code ? $code . ': ' : '') . $title, 0, 255);
|
||||
try {
|
||||
Database::query(
|
||||
'INSERT INTO review_schedule (organization_id, entity_type, entity_id, title, next_review_date, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE title = VALUES(title), next_review_date = VALUES(next_review_date)',
|
||||
[$orgId, 'internal_audit', $auditId, $label, $plannedDate, $this->getCurrentUserId()]
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
// l'ENUM review_schedule.entity_type potrebbe non includere ancora 'internal_audit'
|
||||
// (estensione lato seeder/flotta): non bloccare la creazione dell'audit.
|
||||
error_log('[InternalAudit] calendar upsert skipped: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function assertAudit(int $id, int $orgId): array
|
||||
{
|
||||
$a = Database::fetchOne('SELECT id, code FROM internal_audits WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); }
|
||||
return $a;
|
||||
}
|
||||
|
||||
/** Verifica che la voce appartenga a un audit dell'org (anti-IDOR). */
|
||||
private function assertItem(int $itemId, int $orgId): array
|
||||
{
|
||||
$item = Database::fetchOne(
|
||||
'SELECT i.id, i.audit_id FROM internal_audit_items i
|
||||
JOIN internal_audits a ON a.id = i.audit_id
|
||||
WHERE i.id = ? AND a.organization_id = ?',
|
||||
[$itemId, $orgId]
|
||||
);
|
||||
if (!$item) { $this->jsonError('Voce non trovata', 404, 'NOT_FOUND'); }
|
||||
return $item;
|
||||
}
|
||||
|
||||
private function validateUser($id, int $orgId): ?int
|
||||
{
|
||||
$id = ($id === null || $id === '') ? null : (int) $id;
|
||||
if ($id === null) { return null; }
|
||||
// l'utente deve essere membro dell'org (anti-IDOR)
|
||||
$row = Database::fetchOne(
|
||||
'SELECT u.id FROM users u
|
||||
JOIN user_organizations uo ON uo.user_id = u.id
|
||||
WHERE u.id = ? AND uo.organization_id = ?',
|
||||
[$id, $orgId]
|
||||
);
|
||||
if (!$row) { $this->jsonError('Auditor capo non valido', 422, 'INVALID_AUDITOR'); }
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function validateRole($id, int $orgId): ?int
|
||||
{
|
||||
$id = ($id === null || $id === '') ? null : (int) $id;
|
||||
if ($id === null) { return null; }
|
||||
$row = Database::fetchOne('SELECT id FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$row) { $this->jsonError('Ruolo auditor non valido', 422, 'INVALID_ROLE'); }
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function validateDate($v, string $field): ?string
|
||||
{
|
||||
if ($v === null || $v === '') { return null; }
|
||||
$d = trim((string) $v);
|
||||
$dt = DateTime::createFromFormat('Y-m-d', $d);
|
||||
if (!$dt || $dt->format('Y-m-d') !== $d) { $this->jsonError("Data $field non valida (atteso AAAA-MM-GG)", 422, 'INVALID_DATE'); }
|
||||
return $d;
|
||||
}
|
||||
|
||||
private function nullableStr($v, ?int $max = null): ?string
|
||||
{
|
||||
if ($v === null) { return null; }
|
||||
$s = trim((string) $v);
|
||||
if ($s === '') { return null; }
|
||||
if ($max !== null && mb_strlen($s) > $max) { $s = mb_substr($s, 0, $max); }
|
||||
return $s;
|
||||
}
|
||||
|
||||
/** Report HTML stampabile, self-contained (no PDF lib, no asset esterni). */
|
||||
private function renderReport(array $a, array $items): string
|
||||
{
|
||||
$esc = static fn($s) => htmlspecialchars((string) ($s ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
$resLabels = [
|
||||
'da_verificare' => 'Da verificare', 'conforme' => 'Conforme', 'non_conforme' => 'Non conforme',
|
||||
'osservazione' => 'Osservazione', 'opportunita' => 'Opportunità', 'non_applicabile' => 'Non applicabile',
|
||||
];
|
||||
$resColors = [
|
||||
'da_verificare' => '#6b7280', 'conforme' => '#166534', 'non_conforme' => '#991b1b',
|
||||
'osservazione' => '#92400e', 'opportunita' => '#1e40af', 'non_applicabile' => '#6b7280',
|
||||
];
|
||||
$typeLabels = ['clause' => 'Clausole ISO 27001', 'annex_control' => 'Controlli Annex A', 'nis2_measure' => 'Misure NIS2', 'custom' => 'Voci aggiuntive'];
|
||||
|
||||
// raggruppa per ref_type mantenendo l'ordine
|
||||
$groups = [];
|
||||
foreach ($items as $it) { $groups[$it['ref_type']][] = $it; }
|
||||
|
||||
// conteggi esiti
|
||||
$tally = [];
|
||||
foreach ($items as $it) { $tally[$it['result']] = ($tally[$it['result']] ?? 0) + 1; }
|
||||
|
||||
$leadParts = [];
|
||||
if (!empty($a['lead_auditor_name'])) { $leadParts[] = $a['lead_auditor_name']; }
|
||||
if (!empty($a['lead_auditor_role_name'])) { $leadParts[] = '(' . $a['lead_auditor_role_name'] . ')'; }
|
||||
$lead = $leadParts ? implode(' ', $leadParts) : '—';
|
||||
|
||||
$h = '<!DOCTYPE html><html lang="it"><head><meta charset="UTF-8">';
|
||||
$h .= '<meta name="viewport" content="width=device-width, initial-scale=1">';
|
||||
$h .= '<title>Report audit interno ' . $esc($a['code']) . '</title><style>';
|
||||
$h .= 'body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2937;max-width:900px;margin:24px auto;padding:0 20px;line-height:1.5;}';
|
||||
$h .= 'h1{font-size:1.5rem;margin:0 0 4px;}h2{font-size:1.05rem;margin:26px 0 10px;border-bottom:2px solid #e5e7eb;padding-bottom:5px;}';
|
||||
$h .= '.sub{color:#6b7280;font-size:.9rem;margin-bottom:18px;}';
|
||||
$h .= '.meta{width:100%;border-collapse:collapse;font-size:.9rem;margin-bottom:8px;}';
|
||||
$h .= '.meta th{text-align:left;width:170px;color:#6b7280;font-weight:600;vertical-align:top;padding:5px 10px 5px 0;}';
|
||||
$h .= '.meta td{padding:5px 0;vertical-align:top;}';
|
||||
$h .= 'table.cl{width:100%;border-collapse:collapse;font-size:.86rem;margin-bottom:10px;}';
|
||||
$h .= 'table.cl th,table.cl td{text-align:left;padding:7px 9px;border:1px solid #e5e7eb;vertical-align:top;}';
|
||||
$h .= 'table.cl th{background:#f9fafb;font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;color:#6b7280;}';
|
||||
$h .= '.pill{display:inline-block;font-size:.72rem;font-weight:700;padding:2px 8px;border-radius:6px;color:#fff;white-space:nowrap;}';
|
||||
$h .= '.tally span{display:inline-block;margin-right:10px;font-size:.85rem;}';
|
||||
$h .= '.print-btn{margin:0 0 18px;padding:8px 16px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:8px;cursor:pointer;font-size:.9rem;}';
|
||||
$h .= '@media print{.print-btn{display:none;}body{margin:0;}}';
|
||||
$h .= '</style></head><body>';
|
||||
$h .= '<button class="print-btn" onclick="window.print()">Stampa / Salva PDF</button>';
|
||||
$h .= '<h1>Report di audit interno</h1>';
|
||||
$h .= '<div class="sub">' . $esc($a['org_name']) . ' · ISO/IEC 27001 §9.2 · generato il ' . date('d/m/Y H:i') . '</div>';
|
||||
|
||||
$h .= '<table class="meta">';
|
||||
$h .= '<tr><th>Codice</th><td>' . $esc($a['code']) . '</td></tr>';
|
||||
$h .= '<tr><th>Titolo</th><td>' . $esc($a['title']) . '</td></tr>';
|
||||
$h .= '<tr><th>Stato</th><td>' . $esc($a['status']) . '</td></tr>';
|
||||
$h .= '<tr><th>Auditor capo</th><td>' . $esc($lead) . '</td></tr>';
|
||||
$h .= '<tr><th>Data pianificata</th><td>' . ($a['planned_date'] ? $esc(date('d/m/Y', strtotime($a['planned_date']))) : '—') . '</td></tr>';
|
||||
$h .= '<tr><th>Data esecuzione</th><td>' . ($a['executed_date'] ? $esc(date('d/m/Y', strtotime($a['executed_date']))) : '—') . '</td></tr>';
|
||||
$h .= '<tr><th>Ambito (scope)</th><td>' . nl2br($esc($a['scope'])) . '</td></tr>';
|
||||
$h .= '<tr><th>Criteri</th><td>' . nl2br($esc($a['criteria'])) . '</td></tr>';
|
||||
$h .= '</table>';
|
||||
|
||||
$h .= '<h2>Sintesi esiti</h2><div class="tally">';
|
||||
foreach ($resLabels as $k => $lbl) {
|
||||
$c = $tally[$k] ?? 0;
|
||||
$h .= '<span><span class="pill" style="background:' . $resColors[$k] . '">' . $esc($lbl) . '</span> ' . $c . '</span>';
|
||||
}
|
||||
$h .= '</div>';
|
||||
|
||||
foreach ($typeLabels as $type => $label) {
|
||||
if (empty($groups[$type])) { continue; }
|
||||
$h .= '<h2>' . $esc($label) . '</h2>';
|
||||
$h .= '<table class="cl"><thead><tr><th style="width:70px;">Rif.</th><th>Punto di verifica</th><th style="width:120px;">Esito</th><th>Note</th></tr></thead><tbody>';
|
||||
foreach ($groups[$type] as $it) {
|
||||
$col = $resColors[$it['result']] ?? '#6b7280';
|
||||
$rl = $resLabels[$it['result']] ?? $it['result'];
|
||||
$ncSuffix = $it['ncr'] ? ' <em style="color:#991b1b;font-size:.78rem;">NC ' . $esc($it['ncr']['ncr_code']) . '</em>' : '';
|
||||
$h .= '<tr><td>' . $esc($it['ref_code']) . '</td><td>' . $esc($it['checkpoint']) . $ncSuffix . '</td>';
|
||||
$h .= '<td><span class="pill" style="background:' . $col . '">' . $esc($rl) . '</span></td>';
|
||||
$h .= '<td>' . nl2br($esc($it['note'])) . '</td></tr>';
|
||||
}
|
||||
$h .= '</tbody></table>';
|
||||
}
|
||||
|
||||
if (!empty($a['conclusion'])) {
|
||||
$h .= '<h2>Conclusioni</h2><p>' . nl2br($esc($a['conclusion'])) . '</p>';
|
||||
}
|
||||
|
||||
$h .= '<p style="margin-top:30px;color:#9ca3af;font-size:.78rem;">Documento generato da NIS2 Agile. ISO/IEC 27001 §9.2 e\' una buona prassi volontaria; gli obblighi normativi in Italia derivano da NIS2 / D.Lgs. 138/2024. Strumento di supporto organizzativo, non un parere legale.</p>';
|
||||
$h .= '</body></html>';
|
||||
return $h;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,835 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile — Riesame di Direzione (ISO 27001 §9.3) — Modulo B
|
||||
* ----------------------------------------------------------------------------
|
||||
* Produce il VERBALE del riesame periodico del SGSI con:
|
||||
* - INPUT del riesame AGGREGATI automaticamente dai moduli esistenti (gather),
|
||||
* rivisti dall'utente e CONGELATI nello `snapshot` JSON all'approvazione;
|
||||
* - OUTPUT = decisioni (management_review_decisions) con owner (ruolo org),
|
||||
* scadenza e link facoltativo a una CAPA.
|
||||
*
|
||||
* Il `gather` è difensivo by-design: ogni sezione legge il proprio modulo in
|
||||
* try/catch isolato (alcune tabelle potrebbero non esistere ancora — es.
|
||||
* internal_audits/Modulo A, isms_*, normative_*). Se una fonte fallisce o manca,
|
||||
* la sezione viene marcata `available=false` SENZA rompere il resto.
|
||||
*
|
||||
* Multi-tenancy: OGNI query filtra organization_id. Anti-IDOR: owner_role_id e
|
||||
* capa_id collegati a una decisione sono verificati appartenere all'org.
|
||||
* NOTE: Database::query/fetchAll/fetchOne/insert/update/delete; jsonSuccess/Error exit.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
|
||||
class ManagementReviewController extends BaseController
|
||||
{
|
||||
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
|
||||
private const APPROVE_ROLES = ['org_admin'];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// VERBALI (CRUD)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/management-reviews/list */
|
||||
public function list(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT r.id, r.code, r.review_date, r.period_label, r.status, r.approved_at, r.updated_at,
|
||||
u.full_name AS chair_name,
|
||||
(SELECT COUNT(*) FROM management_review_decisions d WHERE d.review_id = r.id) AS n_decisions,
|
||||
(SELECT COUNT(*) FROM management_review_decisions d WHERE d.review_id = r.id AND d.status = \'done\') AS n_done
|
||||
FROM management_reviews r
|
||||
LEFT JOIN users u ON u.id = r.chair_user_id
|
||||
WHERE r.organization_id = ?
|
||||
ORDER BY (r.review_date IS NULL), r.review_date DESC, r.id DESC',
|
||||
[$orgId]
|
||||
);
|
||||
$out = array_map(static fn($r) => [
|
||||
'id' => (int) $r['id'],
|
||||
'code' => $r['code'],
|
||||
'review_date' => $r['review_date'],
|
||||
'period_label' => $r['period_label'],
|
||||
'status' => $r['status'],
|
||||
'chair_name' => $r['chair_name'],
|
||||
'approved_at' => $r['approved_at'],
|
||||
'n_decisions' => (int) $r['n_decisions'],
|
||||
'n_done' => (int) $r['n_done'],
|
||||
'updated_at' => $r['updated_at'],
|
||||
], $rows);
|
||||
$this->jsonSuccess(['reviews' => $out]);
|
||||
}
|
||||
|
||||
/** GET /api/management-reviews/{id} (con decisions + snapshot) */
|
||||
public function get(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$r = Database::fetchOne(
|
||||
'SELECT r.*, u.full_name AS chair_name, a.full_name AS approver_name
|
||||
FROM management_reviews r
|
||||
LEFT JOIN users u ON u.id = r.chair_user_id
|
||||
LEFT JOIN users a ON a.id = r.approved_by
|
||||
WHERE r.id = ? AND r.organization_id = ?',
|
||||
[$id, $orgId]
|
||||
);
|
||||
if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); }
|
||||
|
||||
$att = $r['attendees'] ? json_decode($r['attendees'], true) : [];
|
||||
$snap = $r['snapshot'] ? json_decode($r['snapshot'], true) : null;
|
||||
$this->jsonSuccess([
|
||||
'id' => (int) $r['id'],
|
||||
'code' => $r['code'],
|
||||
'review_date' => $r['review_date'],
|
||||
'period_label' => $r['period_label'],
|
||||
'chair_user_id' => $r['chair_user_id'] !== null ? (int) $r['chair_user_id'] : null,
|
||||
'chair_name' => $r['chair_name'],
|
||||
'attendees' => is_array($att) ? $att : [],
|
||||
'status' => $r['status'],
|
||||
'approved_by' => $r['approved_by'] !== null ? (int) $r['approved_by'] : null,
|
||||
'approver_name' => $r['approver_name'],
|
||||
'approved_at' => $r['approved_at'],
|
||||
'conclusions' => $r['conclusions'],
|
||||
'snapshot' => is_array($snap) ? $snap : null,
|
||||
'created_at' => $r['created_at'],
|
||||
'updated_at' => $r['updated_at'],
|
||||
'decisions' => $this->loadDecisions($id),
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/management-reviews/create (genera code RD-AAAA-NN) */
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$b = $this->getJsonBody();
|
||||
|
||||
$reviewDate = $this->validateDate($b['review_date'] ?? null, 'review_date');
|
||||
$period = $this->nullableStr($b['period_label'] ?? null, 100);
|
||||
$chairId = $this->validateUser($b['chair_user_id'] ?? null, $orgId);
|
||||
$attendees = $this->validateAttendees($b['attendees'] ?? null);
|
||||
$conclusions = $this->nullableStr($b['conclusions'] ?? null);
|
||||
|
||||
$year = $reviewDate ? (int) substr($reviewDate, 0, 4) : (int) date('Y');
|
||||
$code = $this->nextCode($orgId, $year);
|
||||
|
||||
$id = Database::insert('management_reviews', [
|
||||
'organization_id' => $orgId,
|
||||
'code' => $code,
|
||||
'review_date' => $reviewDate,
|
||||
'period_label' => $period,
|
||||
'chair_user_id' => $chairId,
|
||||
'attendees' => $attendees !== null ? json_encode($attendees, JSON_UNESCAPED_UNICODE) : null,
|
||||
'status' => 'draft',
|
||||
'conclusions' => $conclusions,
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
$this->logAudit('mgmt_review_created', 'management_review', (int) $id, ['code' => $code]);
|
||||
$this->jsonSuccess(['id' => (int) $id, 'code' => $code], 'Riesame creato', 201);
|
||||
}
|
||||
|
||||
/** PUT /api/management-reviews/{id} */
|
||||
public function update(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$b = $this->getJsonBody();
|
||||
|
||||
$r = Database::fetchOne('SELECT id, status FROM management_reviews WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); }
|
||||
if ($r['status'] === 'approved') { $this->jsonError('Il riesame è approvato e non può essere modificato', 409, 'REVIEW_APPROVED'); }
|
||||
|
||||
$updates = [];
|
||||
if ($this->hasParam('review_date')) { $updates['review_date'] = $this->validateDate($b['review_date'] ?? null, 'review_date'); }
|
||||
if ($this->hasParam('period_label')) { $updates['period_label'] = $this->nullableStr($b['period_label'] ?? null, 100); }
|
||||
if ($this->hasParam('chair_user_id')) { $updates['chair_user_id'] = $this->validateUser($b['chair_user_id'] ?? null, $orgId); }
|
||||
if ($this->hasParam('attendees')) {
|
||||
$att = $this->validateAttendees($b['attendees'] ?? null);
|
||||
$updates['attendees'] = $att !== null ? json_encode($att, JSON_UNESCAPED_UNICODE) : null;
|
||||
}
|
||||
if ($this->hasParam('conclusions')) { $updates['conclusions'] = $this->nullableStr($b['conclusions'] ?? null); }
|
||||
// snapshot editabile finché draft: l'utente rivede gli input prima del congelamento
|
||||
if ($this->hasParam('snapshot')) {
|
||||
$snap = $b['snapshot'] ?? null;
|
||||
$updates['snapshot'] = is_array($snap) ? json_encode($snap, JSON_UNESCAPED_UNICODE) : null;
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
Database::update('management_reviews', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
}
|
||||
$this->logAudit('mgmt_review_updated', 'management_review', $id, array_keys($updates));
|
||||
$this->jsonSuccess(['id' => $id, 'updated' => array_keys($updates)], 'Riesame aggiornato');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// GATHER — aggregazione automatica degli INPUT del riesame
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/management-reviews/gather
|
||||
* Aggrega gli INPUT del riesame (ISO 27001 §9.3.2) leggendo i moduli esistenti.
|
||||
* Ogni sezione è in try/catch isolato e org-scoped: una fonte mancante/rotta
|
||||
* non blocca le altre. L'utente rivede il risultato e lo congela nello snapshot.
|
||||
*/
|
||||
public function gather(): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$this->jsonSuccess($this->buildGather($orgId));
|
||||
}
|
||||
|
||||
/** Costruisce l'oggetto strutturato delle sezioni di input (riusato da approve). */
|
||||
private function buildGather(int $orgId): array
|
||||
{
|
||||
$sections = [
|
||||
'nonconformities' => $this->sec(fn() => $this->gatherNonConformities($orgId)),
|
||||
'corrective_actions' => $this->sec(fn() => $this->gatherCorrectiveActions($orgId)),
|
||||
'internal_audits' => $this->sec(fn() => $this->gatherInternalAudits($orgId)),
|
||||
'risks' => $this->sec(fn() => $this->gatherRisks($orgId)),
|
||||
'compliance_score' => $this->sec(fn() => $this->gatherComplianceScore($orgId)),
|
||||
'objectives' => $this->sec(fn() => $this->gatherObjectives($orgId)),
|
||||
'training' => $this->sec(fn() => $this->gatherTraining($orgId)),
|
||||
'stakeholders' => $this->sec(fn() => $this->gatherStakeholders($orgId)),
|
||||
'normative' => $this->sec(fn() => $this->gatherNormative($orgId)),
|
||||
'upcoming_deadlines' => $this->sec(fn() => $this->gatherDeadlines($orgId)),
|
||||
];
|
||||
return [
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'sections' => $sections,
|
||||
];
|
||||
}
|
||||
|
||||
/** Wrapper difensivo: esegue $fn; se lancia (tabella mancante, ecc.) marca available=false. */
|
||||
private function sec(callable $fn): array
|
||||
{
|
||||
try {
|
||||
$data = $fn();
|
||||
return array_merge(['available' => true], $data);
|
||||
} catch (\Throwable $e) {
|
||||
return ['available' => false, 'reason' => 'not_available'];
|
||||
}
|
||||
}
|
||||
|
||||
/** 4. Stato NC/azioni correttive — non_conformities aperte. */
|
||||
private function gatherNonConformities(int $orgId): array
|
||||
{
|
||||
$open = ['open', 'investigating', 'action_planned', 'correcting', 'verifying'];
|
||||
$place = implode(',', array_fill(0, count($open), '?'));
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT id, ncr_code, title, severity, status, target_close_date
|
||||
FROM non_conformities
|
||||
WHERE organization_id = ? AND status IN ($place)
|
||||
ORDER BY (target_close_date IS NULL), target_close_date ASC, id DESC
|
||||
LIMIT 100",
|
||||
array_merge([$orgId], $open)
|
||||
);
|
||||
return [
|
||||
'open_count' => count($rows),
|
||||
'items' => array_map(static fn($r) => [
|
||||
'id' => (int) $r['id'], 'code' => $r['ncr_code'], 'title' => $r['title'],
|
||||
'severity' => $r['severity'], 'status' => $r['status'], 'due_date' => $r['target_close_date'],
|
||||
], $rows),
|
||||
];
|
||||
}
|
||||
|
||||
/** 1. Azioni dal riesame precedente / CAPA non chiuse. */
|
||||
private function gatherCorrectiveActions(int $orgId): array
|
||||
{
|
||||
$open = ['planned', 'in_progress'];
|
||||
$place = implode(',', array_fill(0, count($open), '?'));
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT id, capa_code, title, status, due_date, action_type
|
||||
FROM capa_actions
|
||||
WHERE organization_id = ? AND status IN ($place)
|
||||
ORDER BY (due_date IS NULL), due_date ASC, id DESC
|
||||
LIMIT 100",
|
||||
array_merge([$orgId], $open)
|
||||
);
|
||||
return [
|
||||
'open_count' => count($rows),
|
||||
'items' => array_map(static fn($r) => [
|
||||
'id' => (int) $r['id'], 'code' => $r['capa_code'], 'title' => $r['title'],
|
||||
'status' => $r['status'], 'due_date' => $r['due_date'], 'type' => $r['action_type'],
|
||||
], $rows),
|
||||
];
|
||||
}
|
||||
|
||||
/** 3. Risultati audit interni (Modulo A — la tabella potrebbe non esistere). */
|
||||
private function gatherInternalAudits(int $orgId): array
|
||||
{
|
||||
// Se internal_audits non esiste, la query lancia e sec() marca available=false.
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, code, title, status, planned_date, executed_date, conclusion
|
||||
FROM internal_audits
|
||||
WHERE organization_id = ?
|
||||
ORDER BY (planned_date IS NULL), planned_date DESC, id DESC
|
||||
LIMIT 50',
|
||||
[$orgId]
|
||||
);
|
||||
$completed = 0; $planned = 0;
|
||||
foreach ($rows as $r) {
|
||||
if ($r['status'] === 'completed') { $completed++; }
|
||||
elseif (in_array($r['status'], ['planned', 'in_progress'], true)) { $planned++; }
|
||||
}
|
||||
return [
|
||||
'total' => count($rows),
|
||||
'completed' => $completed,
|
||||
'planned' => $planned,
|
||||
'items' => array_map(static fn($r) => [
|
||||
'id' => (int) $r['id'], 'code' => $r['code'], 'title' => $r['title'],
|
||||
'status' => $r['status'], 'planned_date' => $r['planned_date'],
|
||||
'executed_date' => $r['executed_date'],
|
||||
'conclusion' => $r['conclusion'] !== null ? mb_substr((string) $r['conclusion'], 0, 280) : null,
|
||||
], $rows),
|
||||
];
|
||||
}
|
||||
|
||||
/** 7. Stato rischi + trattamenti. */
|
||||
private function gatherRisks(int $orgId): array
|
||||
{
|
||||
$byStatus = Database::fetchAll(
|
||||
'SELECT status, COUNT(*) AS c FROM risks WHERE organization_id = ? GROUP BY status',
|
||||
[$orgId]
|
||||
);
|
||||
$statusMap = [];
|
||||
foreach ($byStatus as $s) { $statusMap[$s['status']] = (int) $s['c']; }
|
||||
|
||||
// risk_treatments NON ha organization_id: si filtra via JOIN su risks (come CalendarController).
|
||||
$treat = Database::fetchOne(
|
||||
"SELECT
|
||||
SUM(CASE WHEN rt.status = 'completed' THEN 1 ELSE 0 END) AS completed,
|
||||
SUM(CASE WHEN rt.status IN ('planned','in_progress') THEN 1 ELSE 0 END) AS open,
|
||||
SUM(CASE WHEN rt.status = 'overdue' OR (rt.due_date IS NOT NULL AND rt.due_date < CURDATE() AND rt.status <> 'completed') THEN 1 ELSE 0 END) AS overdue,
|
||||
COUNT(*) AS total
|
||||
FROM risk_treatments rt JOIN risks r ON r.id = rt.risk_id
|
||||
WHERE r.organization_id = ?",
|
||||
[$orgId]
|
||||
) ?: [];
|
||||
|
||||
$top = Database::fetchAll(
|
||||
"SELECT id, title, inherent_risk_score, residual_risk_score, status
|
||||
FROM risks
|
||||
WHERE organization_id = ? AND status <> 'closed'
|
||||
ORDER BY inherent_risk_score DESC, id DESC LIMIT 10",
|
||||
[$orgId]
|
||||
);
|
||||
return [
|
||||
'total' => (int) array_sum($statusMap),
|
||||
'by_status' => $statusMap,
|
||||
'treatments' => [
|
||||
'total' => (int) ($treat['total'] ?? 0),
|
||||
'completed' => (int) ($treat['completed'] ?? 0),
|
||||
'open' => (int) ($treat['open'] ?? 0),
|
||||
'overdue' => (int) ($treat['overdue'] ?? 0),
|
||||
],
|
||||
'top_risks' => array_map(static fn($r) => [
|
||||
'id' => (int) $r['id'], 'title' => $r['title'],
|
||||
'inherent' => $r['inherent_risk_score'] !== null ? (int) $r['inherent_risk_score'] : null,
|
||||
'residual' => $r['residual_risk_score'] !== null ? (int) $r['residual_risk_score'] : null,
|
||||
'status' => $r['status'],
|
||||
], $top),
|
||||
];
|
||||
}
|
||||
|
||||
/** 5. Risultati monitoraggio/KPI: avanzamento SoA (isms_soa) se presente. */
|
||||
private function gatherComplianceScore(int $orgId): array
|
||||
{
|
||||
$soa = Database::fetchOne(
|
||||
"SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN applicable = 1 THEN 1 ELSE 0 END) AS applicable,
|
||||
ROUND(AVG(CASE WHEN applicable = 1 THEN implementation_pct END)) AS avg_pct,
|
||||
SUM(CASE WHEN applicable = 1 AND implementation_status = 'implemented' THEN 1 ELSE 0 END) AS implemented,
|
||||
SUM(CASE WHEN applicable = 1 AND implementation_status = 'verified' THEN 1 ELSE 0 END) AS verified
|
||||
FROM isms_soa WHERE organization_id = ?",
|
||||
[$orgId]
|
||||
) ?: [];
|
||||
return [
|
||||
'soa_controls_total' => (int) ($soa['total'] ?? 0),
|
||||
'soa_controls_applicable' => (int) ($soa['applicable'] ?? 0),
|
||||
'soa_avg_implementation' => $soa['avg_pct'] !== null ? (int) $soa['avg_pct'] : null,
|
||||
'soa_implemented' => (int) ($soa['implemented'] ?? 0),
|
||||
'soa_verified' => (int) ($soa['verified'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/** 6. Raggiungimento obiettivi SGSI: isms_models.isms_objectives (JSON) se presente. */
|
||||
private function gatherObjectives(int $orgId): array
|
||||
{
|
||||
$row = Database::fetchOne('SELECT isms_objectives FROM isms_models WHERE organization_id = ?', [$orgId]);
|
||||
$objs = ($row && $row['isms_objectives']) ? json_decode($row['isms_objectives'], true) : [];
|
||||
$items = is_array($objs) ? array_values(array_filter(array_map(static function ($o) {
|
||||
if (is_string($o)) { return ['title' => mb_substr($o, 0, 280)]; }
|
||||
if (is_array($o)) {
|
||||
return [
|
||||
'title' => isset($o['title']) ? mb_substr((string) $o['title'], 0, 280)
|
||||
: (isset($o['name']) ? mb_substr((string) $o['name'], 0, 280) : null),
|
||||
'target' => $o['target'] ?? null,
|
||||
'status' => $o['status'] ?? null,
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}, $objs))) : [];
|
||||
return ['count' => count($items), 'items' => $items];
|
||||
}
|
||||
|
||||
/** Formazione non conforme: assegnazioni scadute/non completate. */
|
||||
private function gatherTraining(int $orgId): array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
"SELECT
|
||||
SUM(CASE WHEN status = 'overdue' OR (due_date IS NOT NULL AND due_date < CURDATE() AND status <> 'completed') THEN 1 ELSE 0 END) AS overdue,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
|
||||
COUNT(*) AS total
|
||||
FROM training_assignments WHERE organization_id = ?",
|
||||
[$orgId]
|
||||
) ?: [];
|
||||
return [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'completed' => (int) ($row['completed'] ?? 0),
|
||||
'overdue' => (int) ($row['overdue'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/** 8. Feedback parti interessate: attività stakeholder (stk_activities). */
|
||||
private function gatherStakeholders(int $orgId): array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
"SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END) AS sent,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed
|
||||
FROM stk_activities WHERE organization_id = ?",
|
||||
[$orgId]
|
||||
) ?: [];
|
||||
$recent = Database::fetchAll(
|
||||
'SELECT id, title, type, status, due_date FROM stk_activities
|
||||
WHERE organization_id = ? ORDER BY id DESC LIMIT 10',
|
||||
[$orgId]
|
||||
);
|
||||
return [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'sent' => (int) ($row['sent'] ?? 0),
|
||||
'completed' => (int) ($row['completed'] ?? 0),
|
||||
'items' => array_map(static fn($a) => [
|
||||
'id' => (int) $a['id'], 'title' => $a['title'], 'type' => $a['type'],
|
||||
'status' => $a['status'], 'due_date' => $a['due_date'],
|
||||
], $recent),
|
||||
];
|
||||
}
|
||||
|
||||
/** 9. Aggiornamenti normativi non ACK (normative_updates / normative_ack). */
|
||||
private function gatherNormative(int $orgId): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT u.id, u.title, u.source, u.reference, u.impact_level, u.effective_date
|
||||
FROM normative_updates u
|
||||
WHERE u.is_published = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM normative_ack a
|
||||
WHERE a.normative_update_id = u.id AND a.organization_id = ?
|
||||
)
|
||||
ORDER BY u.published_at DESC
|
||||
LIMIT 50',
|
||||
[$orgId]
|
||||
);
|
||||
return [
|
||||
'pending_count' => count($rows),
|
||||
'items' => array_map(static fn($r) => [
|
||||
'id' => (int) $r['id'], 'title' => $r['title'], 'source' => $r['source'],
|
||||
'reference' => $r['reference'], 'impact' => $r['impact_level'],
|
||||
'effective_date' => $r['effective_date'],
|
||||
], $rows),
|
||||
];
|
||||
}
|
||||
|
||||
/** Scadenze imminenti (review_schedule, 90 gg). */
|
||||
private function gatherDeadlines(int $orgId): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, entity_type, title, next_review_date
|
||||
FROM review_schedule
|
||||
WHERE organization_id = ? AND next_review_date <= DATE_ADD(CURDATE(), INTERVAL 90 DAY)
|
||||
ORDER BY next_review_date ASC
|
||||
LIMIT 100',
|
||||
[$orgId]
|
||||
);
|
||||
$today = date('Y-m-d');
|
||||
return [
|
||||
'count' => count($rows),
|
||||
'items' => array_map(static fn($r) => [
|
||||
'id' => (int) $r['id'], 'entity_type' => $r['entity_type'], 'title' => $r['title'],
|
||||
'next_review_date' => $r['next_review_date'],
|
||||
'overdue' => ($r['next_review_date'] !== null && $r['next_review_date'] < $today),
|
||||
], $rows),
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// DECISIONI (OUTPUT)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** POST /api/management-reviews/{id}/decisions Body: {decision*, owner_role_id?, due_date?, status?, capa_id?} */
|
||||
public function addDecision(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$r = Database::fetchOne('SELECT id, status FROM management_reviews WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); }
|
||||
if ($r['status'] === 'approved') { $this->jsonError('Il riesame è approvato: non si possono aggiungere decisioni', 409, 'REVIEW_APPROVED'); }
|
||||
$b = $this->getJsonBody();
|
||||
|
||||
$decision = trim((string) ($b['decision'] ?? ''));
|
||||
if ($decision === '') { $this->jsonError('Testo della decisione obbligatorio', 422, 'EMPTY_DECISION'); }
|
||||
|
||||
$ord = (int) (Database::fetchOne('SELECT COALESCE(MAX(ord), 0) + 1 AS n FROM management_review_decisions WHERE review_id = ?', [$id])['n'] ?? 1);
|
||||
|
||||
$did = Database::insert('management_review_decisions', [
|
||||
'review_id' => $id,
|
||||
'decision' => mb_substr($decision, 0, 5000),
|
||||
'owner_role_id' => $this->validateRole($b['owner_role_id'] ?? null, $orgId),
|
||||
'due_date' => $this->validateDate($b['due_date'] ?? null, 'due_date'),
|
||||
'status' => in_array($b['status'] ?? '', ['open', 'in_progress', 'done'], true) ? $b['status'] : 'open',
|
||||
'capa_id' => $this->validateCapa($b['capa_id'] ?? null, $orgId),
|
||||
'ord' => $ord,
|
||||
]);
|
||||
$this->logAudit('mgmt_review_decision_added', 'management_review', $id, ['decision_id' => (int) $did]);
|
||||
$this->jsonSuccess(['id' => (int) $did], 'Decisione aggiunta', 201);
|
||||
}
|
||||
|
||||
/** PUT /api/management-reviews/decisions/{subId} */
|
||||
public function updateDecision(int $subId): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$b = $this->getJsonBody();
|
||||
|
||||
// Anti-IDOR: la decisione deve appartenere a un riesame dell'org.
|
||||
$d = Database::fetchOne(
|
||||
'SELECT d.id, r.status AS review_status
|
||||
FROM management_review_decisions d
|
||||
JOIN management_reviews r ON r.id = d.review_id
|
||||
WHERE d.id = ? AND r.organization_id = ?',
|
||||
[$subId, $orgId]
|
||||
);
|
||||
if (!$d) { $this->jsonError('Decisione non trovata', 404, 'NOT_FOUND'); }
|
||||
if ($d['review_status'] === 'approved') { $this->jsonError('Il riesame è approvato: decisione non modificabile', 409, 'REVIEW_APPROVED'); }
|
||||
|
||||
$updates = [];
|
||||
if ($this->hasParam('decision')) {
|
||||
$decision = trim((string) ($b['decision'] ?? ''));
|
||||
if ($decision === '') { $this->jsonError('Testo della decisione obbligatorio', 422, 'EMPTY_DECISION'); }
|
||||
$updates['decision'] = mb_substr($decision, 0, 5000);
|
||||
}
|
||||
if ($this->hasParam('owner_role_id')) { $updates['owner_role_id'] = $this->validateRole($b['owner_role_id'] ?? null, $orgId); }
|
||||
if ($this->hasParam('due_date')) { $updates['due_date'] = $this->validateDate($b['due_date'] ?? null, 'due_date'); }
|
||||
if ($this->hasParam('status') && in_array($b['status'], ['open', 'in_progress', 'done'], true)) { $updates['status'] = $b['status']; }
|
||||
if ($this->hasParam('capa_id')) { $updates['capa_id'] = $this->validateCapa($b['capa_id'] ?? null, $orgId); }
|
||||
|
||||
if (!empty($updates)) {
|
||||
Database::update('management_review_decisions', $updates, 'id = ?', [$subId]);
|
||||
}
|
||||
$this->logAudit('mgmt_review_decision_updated', 'management_review_decision', $subId, array_keys($updates));
|
||||
$this->jsonSuccess(['id' => $subId, 'updated' => array_keys($updates)], 'Decisione aggiornata');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// APPROVE — congela snapshot + immutabilità
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** POST /api/management-reviews/{id}/approve (org_admin) */
|
||||
public function approve(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::APPROVE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$r = Database::fetchOne('SELECT id, status, snapshot FROM management_reviews WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); }
|
||||
if ($r['status'] === 'approved') { $this->jsonError('Riesame già approvato', 409, 'ALREADY_APPROVED'); }
|
||||
|
||||
// Congela lo snapshot: usa quello già salvato (rivisto dall'utente) oppure,
|
||||
// se assente, ricalcola gli input aggregati al momento dell'approvazione.
|
||||
$existing = $r['snapshot'] ? json_decode($r['snapshot'], true) : null;
|
||||
$snapshot = is_array($existing) && !empty($existing) ? $existing : $this->buildGather($orgId);
|
||||
$snapshot['frozen_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
Database::update('management_reviews', [
|
||||
'status' => 'approved',
|
||||
'approved_by' => $this->getCurrentUserId(),
|
||||
'approved_at' => date('Y-m-d H:i:s'),
|
||||
'snapshot' => json_encode($snapshot, JSON_UNESCAPED_UNICODE),
|
||||
], 'id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
|
||||
$this->logAudit('mgmt_review_approved', 'management_review', $id, ['frozen' => true]);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'approved'], 'Riesame approvato');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// REPORT — verbale HTML stampabile
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/management-reviews/{id}/report (verbale HTML stampabile) */
|
||||
public function report(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member', 'auditor']);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$r = Database::fetchOne(
|
||||
'SELECT r.*, u.full_name AS chair_name, a.full_name AS approver_name, o.name AS org_name
|
||||
FROM management_reviews r
|
||||
LEFT JOIN users u ON u.id = r.chair_user_id
|
||||
LEFT JOIN users a ON a.id = r.approved_by
|
||||
LEFT JOIN organizations o ON o.id = r.organization_id
|
||||
WHERE r.id = ? AND r.organization_id = ?',
|
||||
[$id, $orgId]
|
||||
);
|
||||
if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); }
|
||||
|
||||
$decisions = $this->loadDecisions($id);
|
||||
$snapshot = $r['snapshot'] ? json_decode($r['snapshot'], true) : ($r['status'] === 'approved' ? null : $this->buildGather($orgId));
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo $this->renderReportHtml($r, $decisions, is_array($snapshot) ? $snapshot : ['sections' => []]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// HELPER
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function loadDecisions(int $reviewId): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT d.id, d.decision, d.owner_role_id, ro.role_name AS owner_role_name,
|
||||
d.due_date, d.status, d.capa_id, c.capa_code, d.ord
|
||||
FROM management_review_decisions d
|
||||
LEFT JOIN org_roles ro ON ro.id = d.owner_role_id
|
||||
LEFT JOIN capa_actions c ON c.id = d.capa_id
|
||||
WHERE d.review_id = ? ORDER BY d.ord ASC, d.id ASC',
|
||||
[$reviewId]
|
||||
);
|
||||
return array_map(static fn($d) => [
|
||||
'id' => (int) $d['id'],
|
||||
'decision' => $d['decision'],
|
||||
'owner_role_id' => $d['owner_role_id'] !== null ? (int) $d['owner_role_id'] : null,
|
||||
'owner_role_name' => $d['owner_role_name'],
|
||||
'due_date' => $d['due_date'],
|
||||
'status' => $d['status'],
|
||||
'capa_id' => $d['capa_id'] !== null ? (int) $d['capa_id'] : null,
|
||||
'capa_code' => $d['capa_code'],
|
||||
'ord' => (int) $d['ord'],
|
||||
], $rows);
|
||||
}
|
||||
|
||||
/** Genera RD-AAAA-NN univoco per org+anno (NN progressivo, 2 cifre). */
|
||||
private function nextCode(int $orgId, int $year): string
|
||||
{
|
||||
$prefix = 'RD-' . $year . '-';
|
||||
// progressivo NUMERICO (non lessicografico: 'RD-2026-100' verrebbe < 'RD-2026-99')
|
||||
$row = Database::fetchOne(
|
||||
"SELECT MAX(CAST(SUBSTRING_INDEX(code, '-', -1) AS UNSIGNED)) AS n
|
||||
FROM management_reviews WHERE organization_id = ? AND code LIKE ?",
|
||||
[$orgId, $prefix . '%']
|
||||
);
|
||||
$next = ((int) ($row['n'] ?? 0)) + 1;
|
||||
return $prefix . str_pad((string) $next, 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function validateUser($id, int $orgId): ?int
|
||||
{
|
||||
$id = ($id === null || $id === '') ? null : (int) $id;
|
||||
if ($id === null) { return null; }
|
||||
// l'utente deve essere membro dell'org (anti-IDOR)
|
||||
$ok = Database::fetchOne(
|
||||
'SELECT user_id FROM user_organizations WHERE user_id = ? AND organization_id = ?',
|
||||
[$id, $orgId]
|
||||
);
|
||||
if (!$ok) { $this->jsonError('Utente presidente non valido per questa organizzazione', 422, 'INVALID_CHAIR'); }
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function validateRole($id, int $orgId): ?int
|
||||
{
|
||||
$id = ($id === null || $id === '') ? null : (int) $id;
|
||||
if ($id === null) { return null; }
|
||||
$ok = Database::fetchOne('SELECT id FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$ok) { $this->jsonError('Ruolo owner non valido', 422, 'INVALID_ROLE'); }
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function validateCapa($id, int $orgId): ?int
|
||||
{
|
||||
$id = ($id === null || $id === '') ? null : (int) $id;
|
||||
if ($id === null) { return null; }
|
||||
$ok = Database::fetchOne('SELECT id FROM capa_actions WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$ok) { $this->jsonError('Azione CAPA collegata non valida', 422, 'INVALID_CAPA'); }
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function validateAttendees($raw): ?array
|
||||
{
|
||||
if ($raw === null) { return null; }
|
||||
if (!is_array($raw)) { $this->jsonError('attendees deve essere un array', 422, 'INVALID_ATTENDEES'); }
|
||||
$out = [];
|
||||
foreach ($raw as $a) {
|
||||
if (is_string($a)) { $name = trim($a); $role = ''; }
|
||||
elseif (is_array($a)) { $name = trim((string) ($a['name'] ?? '')); $role = trim((string) ($a['role'] ?? '')); }
|
||||
else { continue; }
|
||||
if ($name === '') { continue; }
|
||||
$item = ['name' => mb_substr($name, 0, 150)];
|
||||
if ($role !== '') { $item['role'] = mb_substr($role, 0, 150); }
|
||||
$out[] = $item;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function validateDate($v, string $field): ?string
|
||||
{
|
||||
if ($v === null || $v === '') { return null; }
|
||||
$d = trim((string) $v);
|
||||
$dt = DateTime::createFromFormat('Y-m-d', $d);
|
||||
if (!$dt || $dt->format('Y-m-d') !== $d) { $this->jsonError("Data $field non valida (atteso AAAA-MM-GG)", 422, 'INVALID_DATE'); }
|
||||
return $d;
|
||||
}
|
||||
|
||||
private function nullableStr($v, ?int $max = null): ?string
|
||||
{
|
||||
if ($v === null) { return null; }
|
||||
$s = trim((string) $v);
|
||||
if ($s === '') { return null; }
|
||||
if ($max !== null && mb_strlen($s) > $max) { $s = mb_substr($s, 0, $max); }
|
||||
return $s;
|
||||
}
|
||||
|
||||
/** HTML del verbale (stampabile). Tutto escapato (esc). */
|
||||
private function renderReportHtml(array $r, array $decisions, array $snapshot): string
|
||||
{
|
||||
$esc = static fn($s) => htmlspecialchars((string) ($s ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
$sections = $snapshot['sections'] ?? [];
|
||||
|
||||
$stLabel = $r['status'] === 'approved' ? 'APPROVATO' : 'BOZZA';
|
||||
$att = $r['attendees'] ? json_decode($r['attendees'], true) : [];
|
||||
$attList = '';
|
||||
if (is_array($att) && $att) {
|
||||
$attList = '<ul>' . implode('', array_map(static function ($a) use ($esc) {
|
||||
$n = is_array($a) ? ($a['name'] ?? '') : $a;
|
||||
$ro = is_array($a) ? ($a['role'] ?? '') : '';
|
||||
return '<li>' . $esc($n) . ($ro ? ' — <em>' . $esc($ro) . '</em>' : '') . '</li>';
|
||||
}, $att)) . '</ul>';
|
||||
} else {
|
||||
$attList = '<p class="muted">Nessun partecipante indicato.</p>';
|
||||
}
|
||||
|
||||
// Tabella decisioni
|
||||
$decRows = '';
|
||||
if ($decisions) {
|
||||
foreach ($decisions as $d) {
|
||||
$stMap = ['open' => 'Aperta', 'in_progress' => 'In corso', 'done' => 'Conclusa'];
|
||||
$decRows .= '<tr><td>' . $esc($d['decision']) . '</td>'
|
||||
. '<td>' . $esc($d['owner_role_name'] ?: '—') . '</td>'
|
||||
. '<td>' . $esc($d['due_date'] ?: '—') . '</td>'
|
||||
. '<td>' . $esc($stMap[$d['status']] ?? $d['status']) . '</td>'
|
||||
. '<td>' . $esc($d['capa_code'] ?: '—') . '</td></tr>';
|
||||
}
|
||||
} else {
|
||||
$decRows = '<tr><td colspan="5" class="muted">Nessuna decisione registrata.</td></tr>';
|
||||
}
|
||||
|
||||
$inputs = $this->renderInputsHtml($sections, $esc);
|
||||
|
||||
return '<!DOCTYPE html><html lang="it"><head><meta charset="utf-8">'
|
||||
. '<title>Verbale Riesame di Direzione ' . $esc($r['code']) . '</title>'
|
||||
. '<style>'
|
||||
. 'body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2937;max-width:900px;margin:24px auto;padding:0 20px;line-height:1.5;}'
|
||||
. 'h1{font-size:1.5rem;border-bottom:3px solid #0066CC;padding-bottom:8px;}'
|
||||
. 'h2{font-size:1.1rem;margin-top:28px;color:#0066CC;border-bottom:1px solid #e5e7eb;padding-bottom:4px;}'
|
||||
. 'h3{font-size:.95rem;margin:18px 0 6px;}'
|
||||
. '.meta{background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;padding:14px 18px;font-size:.9rem;}'
|
||||
. '.meta div{margin:3px 0;} .badge{display:inline-block;padding:2px 10px;border-radius:6px;font-weight:700;font-size:.8rem;}'
|
||||
. '.b-approved{background:#dcfce7;color:#166534;} .b-draft{background:#f3f4f6;color:#6b7280;}'
|
||||
. 'table{width:100%;border-collapse:collapse;font-size:.86rem;margin:8px 0 16px;}'
|
||||
. 'th,td{border:1px solid #e5e7eb;padding:7px 10px;text-align:left;vertical-align:top;}'
|
||||
. 'th{background:#f1f5f9;font-size:.78rem;text-transform:uppercase;letter-spacing:.03em;}'
|
||||
. '.muted{color:#9ca3af;} ul{margin:4px 0;padding-left:20px;}'
|
||||
. '.foot{margin-top:36px;font-size:.78rem;color:#6b7280;border-top:1px solid #e5e7eb;padding-top:10px;}'
|
||||
. '@media print{body{margin:0;} h2{page-break-after:avoid;}}'
|
||||
. '</style></head><body>'
|
||||
. '<h1>Verbale del Riesame di Direzione</h1>'
|
||||
. '<div class="meta">'
|
||||
. '<div><strong>Organizzazione:</strong> ' . $esc($r['org_name']) . '</div>'
|
||||
. '<div><strong>Codice:</strong> ' . $esc($r['code']) . ' <span class="badge ' . ($r['status'] === 'approved' ? 'b-approved' : 'b-draft') . '">' . $stLabel . '</span></div>'
|
||||
. '<div><strong>Data riesame:</strong> ' . $esc($r['review_date'] ?: '—') . '</div>'
|
||||
. '<div><strong>Periodo di riferimento:</strong> ' . $esc($r['period_label'] ?: '—') . '</div>'
|
||||
. '<div><strong>Presidente:</strong> ' . $esc($r['chair_name'] ?: '—') . '</div>'
|
||||
. ($r['status'] === 'approved' ? '<div><strong>Approvato da:</strong> ' . $esc($r['approver_name'] ?: '—') . ' il ' . $esc($r['approved_at']) . '</div>' : '')
|
||||
. '</div>'
|
||||
. '<h2>Partecipanti</h2>' . $attList
|
||||
. '<h2>Elementi in ingresso (ISO/IEC 27001 §9.3.2)</h2>' . $inputs
|
||||
. '<h2>Conclusioni</h2>' . ($r['conclusions'] ? '<p>' . nl2br($esc($r['conclusions'])) . '</p>' : '<p class="muted">Nessuna conclusione registrata.</p>')
|
||||
. '<h2>Decisioni e azioni (elementi in uscita §9.3.3)</h2>'
|
||||
. '<table><thead><tr><th>Decisione</th><th>Responsabile (ruolo)</th><th>Scadenza</th><th>Stato</th><th>CAPA</th></tr></thead><tbody>'
|
||||
. $decRows . '</tbody></table>'
|
||||
. '<div class="foot">Documento generato da NIS2 Agile — riesame periodico del SGSI (ISO/IEC 27001 cl. 9.3; buona prassi di governance NIS2 GV.PO-02). Strumento di supporto, non sostituisce l\'auditor.</div>'
|
||||
. '</body></html>';
|
||||
}
|
||||
|
||||
/** Rende le sezioni di input dello snapshot in HTML compatto. */
|
||||
private function renderInputsHtml(array $sections, callable $esc): string
|
||||
{
|
||||
$titles = [
|
||||
'corrective_actions' => 'Stato azioni dal riesame precedente / CAPA aperte',
|
||||
'stakeholders' => 'Cambiamenti del contesto e parti interessate',
|
||||
'internal_audits' => 'Risultati degli audit interni',
|
||||
'nonconformities' => 'Non conformità e azioni correttive',
|
||||
'compliance_score' => 'Risultati del monitoraggio (avanzamento SoA / KPI)',
|
||||
'objectives' => 'Raggiungimento degli obiettivi del SGSI',
|
||||
'risks' => 'Valutazione dei rischi e stato del trattamento',
|
||||
'normative' => 'Aggiornamenti normativi non riscontrati (ACK)',
|
||||
'training' => 'Formazione e consapevolezza',
|
||||
'upcoming_deadlines' => 'Scadenze imminenti (90 giorni)',
|
||||
];
|
||||
$html = '';
|
||||
foreach ($titles as $key => $title) {
|
||||
$sec = $sections[$key] ?? null;
|
||||
$html .= '<h3>' . $esc($title) . '</h3>';
|
||||
if (!is_array($sec) || empty($sec['available'])) {
|
||||
$html .= '<p class="muted">Dato non disponibile.</p>';
|
||||
continue;
|
||||
}
|
||||
$html .= '<p>' . $esc($this->summarizeSection($key, $sec)) . '</p>';
|
||||
$items = $sec['items'] ?? null;
|
||||
if (is_array($items) && $items) {
|
||||
$html .= '<ul>';
|
||||
foreach (array_slice($items, 0, 15) as $it) {
|
||||
$label = $it['title'] ?? ($it['code'] ?? '');
|
||||
$extra = [];
|
||||
foreach (['code', 'status', 'severity', 'due_date', 'next_review_date', 'impact', 'reference'] as $f) {
|
||||
if (!empty($it[$f]) && $it[$f] !== $label) { $extra[] = $esc($it[$f]); }
|
||||
}
|
||||
$html .= '<li>' . $esc($label) . ($extra ? ' <span class="muted">(' . implode(' · ', $extra) . ')</span>' : '') . '</li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
}
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
|
||||
/** Frase di sintesi numerica per sezione. */
|
||||
private function summarizeSection(string $key, array $sec): string
|
||||
{
|
||||
switch ($key) {
|
||||
case 'nonconformities': return (int) ($sec['open_count'] ?? 0) . ' non conformità aperte.';
|
||||
case 'corrective_actions': return (int) ($sec['open_count'] ?? 0) . ' azioni correttive in corso.';
|
||||
case 'internal_audits': return (int) ($sec['total'] ?? 0) . ' audit interni (' . (int) ($sec['completed'] ?? 0) . ' completati, ' . (int) ($sec['planned'] ?? 0) . ' pianificati).';
|
||||
case 'risks':
|
||||
$t = $sec['treatments'] ?? [];
|
||||
return (int) ($sec['total'] ?? 0) . ' rischi censiti; trattamenti: ' . (int) ($t['completed'] ?? 0) . ' completati, ' . (int) ($t['open'] ?? 0) . ' aperti, ' . (int) ($t['overdue'] ?? 0) . ' in ritardo.';
|
||||
case 'compliance_score':
|
||||
$pct = $sec['soa_avg_implementation'];
|
||||
return 'Avanzamento medio SoA: ' . ($pct !== null ? (int) $pct . '%' : 'n/d') . ' su ' . (int) ($sec['soa_controls_applicable'] ?? 0) . ' controlli applicabili.';
|
||||
case 'objectives': return (int) ($sec['count'] ?? 0) . ' obiettivi SGSI definiti.';
|
||||
case 'training': return (int) ($sec['completed'] ?? 0) . '/' . (int) ($sec['total'] ?? 0) . ' assegnazioni completate, ' . (int) ($sec['overdue'] ?? 0) . ' in ritardo.';
|
||||
case 'stakeholders': return (int) ($sec['total'] ?? 0) . ' attività verso stakeholder (' . (int) ($sec['completed'] ?? 0) . ' completate).';
|
||||
case 'normative': return (int) ($sec['pending_count'] ?? 0) . ' aggiornamenti normativi da riscontrare.';
|
||||
case 'upcoming_deadlines': return (int) ($sec['count'] ?? 0) . ' scadenze nei prossimi 90 giorni.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ class ReviewScheduleController extends BaseController
|
||||
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
|
||||
|
||||
private const ENTITY_TYPES = [
|
||||
'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom', 'stakeholder_activity',
|
||||
'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom', 'stakeholder_activity', 'internal_audit',
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
-- NIS2 Agile - Migration 055: Audit interni (ISO 27001 §9.2)
|
||||
-- ───────────────────────────────────────────────────────────────────
|
||||
-- MODULO A del design DESIGN_AUDIT_INTERNI_RIESAME_DIREZIONE.md
|
||||
-- Ciclo: programma audit -> checklist di conduzione -> esiti -> finding NC.
|
||||
-- Additivo, runner-safe (CREATE TABLE IF NOT EXISTS + FK inline).
|
||||
-- I finding NC confluiscono in non_conformities (source='audit',
|
||||
-- source_entity_type='internal_audit_item'); le evidenze su evidence_files
|
||||
-- (entity_type='internal_audit'). Nessuna nuova tabella file.
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Programma / sessioni di audit interno
|
||||
CREATE TABLE IF NOT EXISTS internal_audits (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
organization_id INT NOT NULL,
|
||||
code VARCHAR(20) NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
scope TEXT NULL,
|
||||
criteria TEXT NULL,
|
||||
planned_date DATE NULL,
|
||||
executed_date DATE NULL,
|
||||
status ENUM('planned','in_progress','completed','cancelled') NOT NULL DEFAULT 'planned',
|
||||
lead_auditor_user_id INT NULL,
|
||||
lead_auditor_role_id INT NULL,
|
||||
conclusion TEXT NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_intaud_org (organization_id),
|
||||
CONSTRAINT fk_intaud_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_intaud_lead_user FOREIGN KEY (lead_auditor_user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_intaud_lead_role FOREIGN KEY (lead_auditor_role_id) REFERENCES org_roles (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_intaud_creator FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Voci di checklist (clausole 4-10, controlli Annex A, misure NIS2, custom)
|
||||
CREATE TABLE IF NOT EXISTS internal_audit_items (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
audit_id INT NOT NULL,
|
||||
ref_type ENUM('clause','annex_control','nis2_measure','custom') NOT NULL DEFAULT 'clause',
|
||||
ref_code VARCHAR(32) NULL,
|
||||
checkpoint TEXT NOT NULL,
|
||||
result ENUM('da_verificare','conforme','non_conforme','osservazione','opportunita','non_applicabile') NOT NULL DEFAULT 'da_verificare',
|
||||
note TEXT NULL,
|
||||
ord INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_intauditem_audit (audit_id),
|
||||
CONSTRAINT fk_intauditem_audit FOREIGN KEY (audit_id) REFERENCES internal_audits (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- NIS2 Agile — Migration 056: Riesame di Direzione (ISO 27001 §9.3)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Modulo B del design docs/DESIGN_AUDIT_INTERNI_RIESAME_DIREZIONE.md.
|
||||
-- Produce il VERBALE del riesame periodico del SGSI con INPUT aggregati dai
|
||||
-- moduli esistenti (snapshot congelato all'approvazione) e OUTPUT = decisioni.
|
||||
--
|
||||
-- RUNNER-SAFE: SOLO CREATE TABLE IF NOT EXISTS con TUTTE le FK inline.
|
||||
-- Niente ALTER ADD CONSTRAINT, niente DELIMITER o stored-proc, niente
|
||||
-- separatori statement nei commenti. Idempotente (re-eseguibile). Charset utf8mb4.
|
||||
-- Applicare con il seeder CLI application/cli/seed_management_reviews.php
|
||||
-- (stessa connessione PDO dell'app — vedi nota mig.052/053).
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS management_reviews (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
organization_id INT NOT NULL,
|
||||
code VARCHAR(20) NULL,
|
||||
review_date DATE NULL,
|
||||
period_label VARCHAR(100) NULL,
|
||||
chair_user_id INT NULL,
|
||||
attendees JSON NULL,
|
||||
status ENUM('draft','approved') NOT NULL DEFAULT 'draft',
|
||||
approved_by INT NULL,
|
||||
approved_at DATETIME NULL,
|
||||
snapshot JSON NULL,
|
||||
conclusions TEXT NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_mgr_org (organization_id),
|
||||
CONSTRAINT fk_mgr_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mgr_chair FOREIGN KEY (chair_user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_mgr_approved FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_mgr_creator FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS management_review_decisions (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
review_id INT NOT NULL,
|
||||
decision TEXT NOT NULL,
|
||||
owner_role_id INT NULL,
|
||||
due_date DATE NULL,
|
||||
status ENUM('open','in_progress','done') NOT NULL DEFAULT 'open',
|
||||
capa_id INT NULL,
|
||||
ord INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_mrd_review (review_id),
|
||||
CONSTRAINT fk_mrd_review FOREIGN KEY (review_id) REFERENCES management_reviews (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mrd_owner FOREIGN KEY (owner_role_id) REFERENCES org_roles (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -70,9 +70,9 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della app: api.js + common.js (helper) + common-bi.js (override BI) -->
|
||||
<script src="js/api.js?v=20260629"></script>
|
||||
<script src="js/common.js?v=20260629"></script>
|
||||
<script src="js/common-bi.js?v=20260629"></script>
|
||||
<script src="js/api.js?v=20260630"></script>
|
||||
<script src="js/common.js?v=20260630"></script>
|
||||
<script src="js/common-bi.js?v=20260630"></script>
|
||||
<script>
|
||||
// Renderizza la sidebar BI (loadSidebar è stato ridefinito da common-bi.js)
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
@@ -1088,10 +1088,10 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js?v=20260629"></script>
|
||||
<script src="js/common.js?v=20260629"></script>
|
||||
<script src="js/i18n.js?v=20260629"></script>
|
||||
<script src="js/help.js?v=20260629"></script>
|
||||
<script src="js/api.js?v=20260630"></script>
|
||||
<script src="js/common.js?v=20260630"></script>
|
||||
<script src="js/i18n.js?v=20260630"></script>
|
||||
<script src="js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
|
||||
@@ -154,8 +154,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -165,9 +165,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
+5
-5
@@ -361,8 +361,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -372,9 +372,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Calendario scadenze - NIS2 Agile</title>
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260630">
|
||||
<style>
|
||||
.cal-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
|
||||
.cal-note { font-size:.82rem; color:#3730a3; background:#eef2ff; border:1px solid #c7d2fe; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
||||
.cal-bar { display:flex; flex-wrap:wrap; gap:12px; align-items:center; justify-content:space-between; margin-bottom:14px; }
|
||||
.cal-nav { display:flex; align-items:center; gap:8px; }
|
||||
.cal-nav h3 { margin:0; font-size:1.05rem; min-width:170px; text-align:center; text-transform:capitalize; }
|
||||
.cal-views { display:flex; gap:6px; }
|
||||
.cal-view-btn { padding:7px 14px; font-weight:600; font-size:.85rem; cursor:pointer; border:1px solid var(--gray-200,#e5e7eb); background:#fff; color:var(--gray-600,#4b5563); border-radius:8px; }
|
||||
.cal-view-btn.active { background:var(--primary,#2563eb); color:#fff; border-color:var(--primary,#2563eb); }
|
||||
.cal-summary { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:14px; }
|
||||
.cal-kpi { flex:1 1 0; min-width:120px; background:#fff; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; padding:10px 14px; }
|
||||
.cal-kpi .n { font-size:1.5rem; font-weight:800; line-height:1; }
|
||||
.cal-kpi .l { font-size:.74rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); margin-top:4px; }
|
||||
.cal-kpi.k-overdue .n { color:#b91c1c; } .cal-kpi.k-due_soon .n { color:#b45309; }
|
||||
.cal-kpi.k-upcoming .n { color:#1d4ed8; } .cal-kpi.k-done .n { color:#15803d; }
|
||||
.cal-filters { background:#f8fafc; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; padding:12px 14px; margin-bottom:14px; }
|
||||
.cal-filters h4 { margin:0 0 8px; font-size:.78rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
|
||||
.cal-chips { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.cal-chip { display:inline-flex; align-items:center; gap:6px; font-size:.82rem; padding:5px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:20px; background:#fff; cursor:pointer; user-select:none; }
|
||||
.cal-chip input { margin:0; cursor:pointer; }
|
||||
.cal-chip .dot { width:10px; height:10px; border-radius:50%; display:inline-block; }
|
||||
.cal-filter-row { margin-top:10px; }
|
||||
table.cal-grid { width:100%; border-collapse:collapse; table-layout:fixed; }
|
||||
table.cal-grid th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); padding:6px 4px; text-align:center; }
|
||||
table.cal-grid td { border:1px solid var(--gray-100,#f3f4f6); vertical-align:top; height:96px; padding:4px; background:#fff; }
|
||||
table.cal-grid td.cal-out { background:#fafafa; }
|
||||
table.cal-grid td.cal-today { background:#fffbeb; outline:2px solid #fbbf24; outline-offset:-2px; }
|
||||
.cal-daynum { font-size:.78rem; font-weight:600; color:var(--gray-600,#4b5563); }
|
||||
.cal-day-dots { display:flex; flex-wrap:wrap; gap:3px; margin-top:4px; }
|
||||
.cal-dot { width:9px; height:9px; border-radius:50%; cursor:pointer; }
|
||||
.cal-more { font-size:.7rem; color:var(--gray-500,#6b7280); cursor:pointer; margin-top:3px; }
|
||||
.st-overdue { background:#dc2626; } .st-due_soon { background:#d97706; }
|
||||
.st-upcoming { background:#2563eb; } .st-done { background:#16a34a; }
|
||||
.cal-list { width:100%; border-collapse:collapse; font-size:.88rem; }
|
||||
.cal-list th, .cal-list td { text-align:left; padding:9px 12px; border-top:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
|
||||
.cal-list th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
|
||||
.cal-list tr.row-overdue td { background:#fef2f2; }
|
||||
.cal-list a.cal-link { color:var(--primary,#2563eb); text-decoration:none; font-weight:600; }
|
||||
.cal-list a.cal-link:hover { text-decoration:underline; }
|
||||
.st-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; white-space:nowrap; color:#fff; }
|
||||
.st-badge.st-overdue { background:#dc2626; } .st-badge.st-due_soon { background:#d97706; }
|
||||
.st-badge.st-upcoming { background:#2563eb; } .st-badge.st-done { background:#16a34a; }
|
||||
.cal-empty { text-align:center; padding:22px 16px; color:var(--gray-500,#6b7280); font-size:.86rem; }
|
||||
.hidden { display:none !important; }
|
||||
.type-tag { display:inline-block; font-size:.7rem; padding:2px 7px; border-radius:6px; background:#eef2ff; color:#3730a3; }
|
||||
/* popover giorno */
|
||||
.cal-pop { position:fixed; inset:0; background:rgba(15,23,42,.4); z-index:1050; display:none; align-items:flex-start; justify-content:center; padding:40px 16px; overflow:auto; }
|
||||
.cal-pop.open { display:flex; }
|
||||
.cal-pop-card { background:#fff; border-radius:12px; width:100%; max-width:520px; box-shadow:0 20px 50px rgba(0,0,0,.25); }
|
||||
.cal-pop-head { display:flex; justify-content:space-between; align-items:center; padding:14px 18px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
|
||||
.cal-pop-head h3 { margin:0; font-size:1rem; }
|
||||
.cal-pop-body { padding:14px 18px; }
|
||||
.cal-pop-item { display:flex; gap:8px; align-items:flex-start; padding:8px 0; border-top:1px solid var(--gray-100,#f3f4f6); }
|
||||
.cal-pop-item:first-child { border-top:none; }
|
||||
.cal-pop-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); }
|
||||
</style>
|
||||
<!-- PWA:start -->
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#0066CC">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
|
||||
<meta name="application-name" content="NIS2 Agile">
|
||||
<script src="/js/pwa.js?v=20260614" defer></script>
|
||||
<!-- PWA:end -->
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<h2 data-i18n="cal.title">Calendario scadenze</h2>
|
||||
</header>
|
||||
<div class="content-body">
|
||||
<div class="cal-intro">
|
||||
<strong>Tutte le scadenze in un solo posto.</strong>
|
||||
Il calendario aggrega <em>in tempo reale</em> ogni scadenza del sistema — incidenti, revisioni policy e controlli,
|
||||
trattamenti di rischio, non conformita e azioni correttive, formazione, attivita stakeholder, scadenziario revisioni,
|
||||
audit interni e decisioni del riesame — con stato e link diretto all'elemento.
|
||||
</div>
|
||||
<div class="cal-note">
|
||||
Ancoraggio: buona prassi di monitoraggio scadenze e revisioni periodiche (<strong>ISO 9.1/9.3</strong>,
|
||||
NIS2 <strong>GV.PO-02 / GV.SC-07 / DE.CM</strong>). Strumento di supporto operativo, non un parere legale.
|
||||
</div>
|
||||
|
||||
<!-- KPI sintetici -->
|
||||
<div class="cal-summary" id="cal-summary" aria-live="polite"></div>
|
||||
|
||||
<!-- Barra: navigazione mese + selettore vista -->
|
||||
<div class="cal-bar">
|
||||
<div class="cal-nav">
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" onclick="calPrevMonth()" aria-label="Mese precedente">‹</button>
|
||||
<h3 id="cal-month-label">—</h3>
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" onclick="calNextMonth()" aria-label="Mese successivo">›</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" onclick="calToday()">Oggi</button>
|
||||
</div>
|
||||
<div class="cal-views" role="tablist" aria-label="Vista calendario">
|
||||
<button class="cal-view-btn active" id="view-grid" role="tab" aria-selected="true" aria-controls="pane-grid" onclick="calSetView('grid')">Griglia</button>
|
||||
<button class="cal-view-btn" id="view-list" role="tab" aria-selected="false" aria-controls="pane-list" onclick="calSetView('list')">Lista</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtri -->
|
||||
<div class="cal-filters">
|
||||
<h4>Filtra per tipo</h4>
|
||||
<div class="cal-chips" id="cal-type-chips"></div>
|
||||
<div class="cal-filter-row">
|
||||
<h4>Filtra per stato</h4>
|
||||
<div class="cal-chips" id="cal-status-chips"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vista griglia mensile -->
|
||||
<section id="pane-grid" role="tabpanel" aria-labelledby="view-grid" aria-live="polite">
|
||||
<table class="cal-grid">
|
||||
<thead>
|
||||
<tr id="cal-weekhead"></tr>
|
||||
</thead>
|
||||
<tbody id="cal-grid-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Vista lista -->
|
||||
<section id="pane-list" class="hidden" role="tabpanel" aria-labelledby="view-list" aria-live="polite">
|
||||
<div id="cal-list-wrap"></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Popover eventi di un giorno -->
|
||||
<div class="cal-pop" id="cal-pop" role="dialog" aria-modal="true" aria-labelledby="cal-pop-title">
|
||||
<div class="cal-pop-card">
|
||||
<div class="cal-pop-head">
|
||||
<h3 id="cal-pop-title">Eventi</h3>
|
||||
<button class="cal-pop-close" type="button" aria-label="Chiudi" onclick="calPopClose()">×</button>
|
||||
</div>
|
||||
<div class="cal-pop-body" id="cal-pop-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
/*
|
||||
* NIS2 Agile - Calendario unico delle scadenze (Modulo C)
|
||||
* Aggregatore READ-ONLY: griglia mensile + lista + filtri (tipo/stato) + deep-link.
|
||||
* Dependency-free: nessuna libreria calendario, render in JS puro.
|
||||
*/
|
||||
var CAL = {
|
||||
view: 'grid',
|
||||
cursor: new Date(), // mese visualizzato (giorno 1)
|
||||
events: [], // tutti gli eventi della finestra caricata
|
||||
rangeFrom: null, rangeTo: null,
|
||||
activeTypes: null, // null = tutti
|
||||
activeStatuses: null, // null = tutti
|
||||
};
|
||||
|
||||
var TYPE_META = {
|
||||
incident_early_warning: { label: 'Incidente - Early Warning', color: '#dc2626' },
|
||||
incident_notification: { label: 'Incidente - Notifica CSIRT', color: '#ea580c' },
|
||||
incident_final_report: { label: 'Incidente - Report finale', color: '#f59e0b' },
|
||||
policy_review: { label: 'Revisione policy', color: '#2563eb' },
|
||||
risk_treatment: { label: 'Trattamento rischio', color: '#7c3aed' },
|
||||
control_review: { label: 'Revisione controllo', color: '#0891b2' },
|
||||
nc_target_close: { label: 'Chiusura non conformita', color: '#be123c' },
|
||||
capa_action: { label: 'Azione correttiva', color: '#c026d3' },
|
||||
training_due: { label: 'Formazione', color: '#16a34a' },
|
||||
stakeholder_activity: { label: 'Attivita stakeholder', color: '#0d9488' },
|
||||
review_schedule: { label: 'Scadenziario revisioni', color: '#4f46e5' },
|
||||
internal_audit: { label: 'Audit interno', color: '#9333ea' },
|
||||
management_review_decision: { label: 'Decisione riesame', color: '#0369a1' }
|
||||
};
|
||||
var STATUS_META = {
|
||||
overdue: { label: 'In ritardo', color: '#dc2626' },
|
||||
due_soon: { label: 'In scadenza', color: '#d97706' },
|
||||
upcoming: { label: 'Futura', color: '#2563eb' },
|
||||
done: { label: 'Assolta', color: '#16a34a' }
|
||||
};
|
||||
var WEEKDAYS = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom'];
|
||||
var MONTHS = ['gennaio','febbraio','marzo','aprile','maggio','giugno','luglio','agosto','settembre','ottobre','novembre','dicembre'];
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
||||
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
|
||||
function ymd(d) {
|
||||
var m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
var day = String(d.getDate()).padStart(2, '0');
|
||||
return d.getFullYear() + '-' + m + '-' + day;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function () {
|
||||
if (typeof checkAuth === 'function' && !checkAuth()) return;
|
||||
if (window.I18n && I18n.init) I18n.init('it');
|
||||
if (typeof loadSidebar === 'function') loadSidebar();
|
||||
if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') calPopClose(); });
|
||||
var pop = el('cal-pop'); if (pop) pop.addEventListener('click', function (e) { if (e.target === pop) calPopClose(); });
|
||||
CAL.cursor = new Date(); CAL.cursor.setDate(1);
|
||||
renderFilters();
|
||||
await calLoad();
|
||||
});
|
||||
|
||||
// ── Caricamento dati (finestra ampia attorno al cursore) ──
|
||||
async function calLoad() {
|
||||
var from = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() - 2, 1);
|
||||
var to = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + 3, 0);
|
||||
CAL.rangeFrom = ymd(from); CAL.rangeTo = ymd(to);
|
||||
el('cal-grid-body').innerHTML = '<tr><td colspan="7" class="cal-empty">Caricamento…</td></tr>';
|
||||
try {
|
||||
var data = await api.calendarEvents({ from: CAL.rangeFrom, to: CAL.rangeTo });
|
||||
CAL.events = (data && data.events) ? data.events : [];
|
||||
} catch (e) {
|
||||
CAL.events = [];
|
||||
el('cal-grid-body').innerHTML = '<tr><td colspan="7" class="cal-empty">Errore nel caricamento: ' + esc(e.message || '') + '</td></tr>';
|
||||
}
|
||||
await loadSummary();
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
try {
|
||||
var s = await api.calendarSummary();
|
||||
var bs = (s && s.by_status) || {};
|
||||
var defs = [['overdue', 'In ritardo'], ['due_soon', 'In scadenza'], ['upcoming', 'Future'], ['done', 'Assolte']];
|
||||
el('cal-summary').innerHTML = defs.map(function (d) {
|
||||
return '<div class="cal-kpi k-' + d[0] + '"><div class="n">' + (bs[d[0]] || 0) + '</div><div class="l">' + d[1] + '</div></div>';
|
||||
}).join('');
|
||||
} catch (e) { el('cal-summary').innerHTML = ''; }
|
||||
}
|
||||
|
||||
// ── Filtri (chip checkbox per tipo e per stato) ──
|
||||
function renderFilters() {
|
||||
el('cal-type-chips').innerHTML = Object.keys(TYPE_META).map(function (t) {
|
||||
var m = TYPE_META[t];
|
||||
return '<label class="cal-chip"><input type="checkbox" class="f-type" value="' + escAttr(t) + '" checked onchange="onTypeFilter()">' +
|
||||
'<span class="dot" style="background:' + m.color + '"></span>' + esc(m.label) + '</label>';
|
||||
}).join('');
|
||||
el('cal-status-chips').innerHTML = Object.keys(STATUS_META).map(function (s) {
|
||||
var m = STATUS_META[s];
|
||||
return '<label class="cal-chip"><input type="checkbox" class="f-status" value="' + escAttr(s) + '" checked onchange="onStatusFilter()">' +
|
||||
'<span class="dot" style="background:' + m.color + '"></span>' + esc(m.label) + '</label>';
|
||||
}).join('');
|
||||
}
|
||||
function onTypeFilter() {
|
||||
var all = Array.prototype.slice.call(document.querySelectorAll('.f-type'));
|
||||
var checked = all.filter(function (c) { return c.checked; }).map(function (c) { return c.value; });
|
||||
CAL.activeTypes = (checked.length === all.length) ? null : checked;
|
||||
render();
|
||||
}
|
||||
function onStatusFilter() {
|
||||
var all = Array.prototype.slice.call(document.querySelectorAll('.f-status'));
|
||||
var checked = all.filter(function (c) { return c.checked; }).map(function (c) { return c.value; });
|
||||
CAL.activeStatuses = (checked.length === all.length) ? null : checked;
|
||||
render();
|
||||
}
|
||||
|
||||
function visibleEvents() {
|
||||
return CAL.events.filter(function (e) {
|
||||
if (CAL.activeTypes && CAL.activeTypes.indexOf(e.type) < 0) return false;
|
||||
if (CAL.activeStatuses && CAL.activeStatuses.indexOf(e.status) < 0) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Navigazione mese / vista ──
|
||||
function calPrevMonth() { CAL.cursor.setMonth(CAL.cursor.getMonth() - 1); maybeReload(); }
|
||||
function calNextMonth() { CAL.cursor.setMonth(CAL.cursor.getMonth() + 1); maybeReload(); }
|
||||
function calToday() { CAL.cursor = new Date(); CAL.cursor.setDate(1); maybeReload(); }
|
||||
function maybeReload() {
|
||||
// Ricarica dal server solo se il mese esce dalla finestra gia' caricata.
|
||||
var first = ymd(new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth(), 1));
|
||||
var last = ymd(new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + 1, 0));
|
||||
if (!CAL.rangeFrom || first < CAL.rangeFrom || last > CAL.rangeTo) { calLoad(); }
|
||||
else { render(); }
|
||||
}
|
||||
function calSetView(v) {
|
||||
CAL.view = v;
|
||||
el('view-grid').classList.toggle('active', v === 'grid');
|
||||
el('view-list').classList.toggle('active', v === 'list');
|
||||
el('view-grid').setAttribute('aria-selected', v === 'grid' ? 'true' : 'false');
|
||||
el('view-list').setAttribute('aria-selected', v === 'list' ? 'true' : 'false');
|
||||
el('pane-grid').classList.toggle('hidden', v !== 'grid');
|
||||
el('pane-list').classList.toggle('hidden', v !== 'list');
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
el('cal-month-label').textContent = MONTHS[CAL.cursor.getMonth()] + ' ' + CAL.cursor.getFullYear();
|
||||
if (CAL.view === 'grid') renderGrid(); else renderList();
|
||||
}
|
||||
|
||||
// ── Vista griglia mensile ──
|
||||
function renderGrid() {
|
||||
el('cal-weekhead').innerHTML = WEEKDAYS.map(function (w) { return '<th>' + w + '</th>'; }).join('');
|
||||
|
||||
var year = CAL.cursor.getFullYear(), month = CAL.cursor.getMonth();
|
||||
var firstOfMonth = new Date(year, month, 1);
|
||||
// settimana inizia lunedi (getDay: 0=dom)
|
||||
var startOffset = (firstOfMonth.getDay() + 6) % 7;
|
||||
var gridStart = new Date(year, month, 1 - startOffset);
|
||||
var todayStr = ymd(new Date());
|
||||
|
||||
// raggruppa eventi visibili per giorno
|
||||
var byDay = {};
|
||||
visibleEvents().forEach(function (e) { (byDay[e.date] = byDay[e.date] || []).push(e); });
|
||||
|
||||
var html = '';
|
||||
for (var w = 0; w < 6; w++) {
|
||||
html += '<tr>';
|
||||
for (var d = 0; d < 7; d++) {
|
||||
var cur = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + (w * 7 + d));
|
||||
var key = ymd(cur);
|
||||
var inMonth = (cur.getMonth() === month);
|
||||
var cls = inMonth ? '' : ' cal-out';
|
||||
if (key === todayStr) cls += ' cal-today';
|
||||
var evs = byDay[key] || [];
|
||||
var dots = '';
|
||||
var shown = evs.slice(0, 6);
|
||||
shown.forEach(function (e) {
|
||||
var c = (TYPE_META[e.type] || {}).color || '#64748b';
|
||||
var tip = escAttr((TYPE_META[e.type] || { label: e.type }).label + ' — ' + e.title);
|
||||
dots += '<span class="cal-dot st-' + esc(e.status) + '" style="background:' + c + '" title="' + tip + '" onclick="calPopDay(\'' + key + '\')"></span>';
|
||||
});
|
||||
var more = evs.length > shown.length ? '<div class="cal-more" onclick="calPopDay(\'' + key + '\')">+' + (evs.length - shown.length) + ' altri</div>' : '';
|
||||
var clickDay = evs.length ? ' style="cursor:pointer" role="button" tabindex="0" aria-label="' + escAttr(cur.getDate() + ': ' + evs.length + ' scadenze') + '" onclick="calPopDay(\'' + key + '\')" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();calPopDay(\'' + key + '\');}"' : '';
|
||||
html += '<td class="' + cls + '">' +
|
||||
'<div class="cal-daynum"' + clickDay + '>' + cur.getDate() + '</div>' +
|
||||
(evs.length ? '<div class="cal-day-dots">' + dots + '</div>' + more : '') +
|
||||
'</td>';
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
el('cal-grid-body').innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Vista lista (overdue in cima, poi per data) ──
|
||||
function renderList() {
|
||||
var evs = visibleEvents().slice().sort(function (a, b) {
|
||||
var oa = a.status === 'overdue' ? 0 : 1, ob = b.status === 'overdue' ? 0 : 1;
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.date < b.date ? -1 : (a.date > b.date ? 1 : 0);
|
||||
});
|
||||
if (!evs.length) { el('cal-list-wrap').innerHTML = '<div class="cal-empty">Nessuna scadenza per i filtri selezionati.</div>'; return; }
|
||||
var rows = evs.map(function (e) {
|
||||
var m = TYPE_META[e.type] || { label: e.type, color: '#64748b' };
|
||||
var sm = STATUS_META[e.status] || { label: e.status };
|
||||
return '<tr class="row-' + esc(e.status) + '">' +
|
||||
'<td>' + esc(formatDate(e.date)) + '</td>' +
|
||||
'<td><span class="type-tag" style="background:' + m.color + '22;color:' + m.color + '">' + esc(m.label) + '</span></td>' +
|
||||
'<td><a class="cal-link" href="' + escAttr(e.link || '#') + '">' + esc(e.title) + '</a></td>' +
|
||||
'<td><span class="st-badge st-' + esc(e.status) + '">' + esc(sm.label) + '</span></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
el('cal-list-wrap').innerHTML =
|
||||
'<table class="cal-list"><thead><tr><th>Data</th><th>Tipo</th><th>Elemento</th><th>Stato</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
function formatDate(s) {
|
||||
var p = String(s).split('-');
|
||||
if (p.length !== 3) return s;
|
||||
return p[2] + '/' + p[1] + '/' + p[0];
|
||||
}
|
||||
|
||||
// ── Popover eventi di un giorno ──
|
||||
function calPopDay(dateKey) {
|
||||
var evs = visibleEvents().filter(function (e) { return e.date === dateKey; });
|
||||
if (!evs.length) return;
|
||||
el('cal-pop-title').textContent = 'Scadenze del ' + formatDate(dateKey);
|
||||
el('cal-pop-body').innerHTML = evs.map(function (e) {
|
||||
var m = TYPE_META[e.type] || { label: e.type, color: '#64748b' };
|
||||
var sm = STATUS_META[e.status] || { label: e.status };
|
||||
return '<div class="cal-pop-item">' +
|
||||
'<span class="cal-dot st-' + esc(e.status) + '" style="background:' + m.color + ';margin-top:4px;flex:0 0 auto"></span>' +
|
||||
'<div style="flex:1 1 auto">' +
|
||||
'<div><a class="cal-link" href="' + escAttr(e.link || '#') + '">' + esc(e.title) + '</a></div>' +
|
||||
'<div style="font-size:.78rem;color:#6b7280;margin-top:2px">' + esc(m.label) +
|
||||
' · <span class="st-badge st-' + esc(e.status) + '">' + esc(sm.label) + '</span></div>' +
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
el('cal-pop').classList.add('open');
|
||||
}
|
||||
function calPopClose() { el('cal-pop').classList.remove('open'); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -349,14 +349,14 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js?v=20260629"></script>
|
||||
<script src="js/common.js?v=20260629"></script>
|
||||
<script src="js/api.js?v=20260630"></script>
|
||||
<script src="js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle + override sidebar BI (common-bi.js dopo common.js: ridefinisce solo loadSidebar) -->
|
||||
<script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script>if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }</script>
|
||||
<script src="js/common-bi.js?v=20260629"></script>
|
||||
<script src="js/i18n.js?v=20260629"></script>
|
||||
<script src="js/help.js?v=20260629"></script>
|
||||
<script src="js/common-bi.js?v=20260630"></script>
|
||||
<script src="js/i18n.js?v=20260630"></script>
|
||||
<script src="js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
|
||||
@@ -183,12 +183,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script src="/js/competenze.js?v=20260617"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -382,8 +382,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -393,8 +393,8 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script>
|
||||
const SPRITE = '/vendor/bootstrap-italia/dist/svg/sprites.svg';
|
||||
function ico(name, cls) { return `<svg class="ico ${cls||''}" aria-hidden="true"><use href="${SPRITE}#${name}"></use></svg>`; }
|
||||
|
||||
@@ -143,8 +143,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -154,9 +154,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della forgot-password.html: ZERO modifiche backend -->
|
||||
<script src="js/api.js?v=20260629"></script>
|
||||
<script src="js/api.js?v=20260630"></script>
|
||||
<script>
|
||||
const form = document.getElementById('forgot-form');
|
||||
const err = document.getElementById('err');
|
||||
|
||||
+5
-5
@@ -1142,8 +1142,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (accordion) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. -->
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
@@ -1152,9 +1152,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
// La guida è accessibile anche da non autenticati (utile per onboarding)
|
||||
// ma se sei loggato carichi sidebar + i18n normalmente. Stessa logica
|
||||
|
||||
@@ -351,8 +351,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -362,9 +362,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
// ── Auth & Init ──────────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -94,6 +94,9 @@ $controllerMap = [
|
||||
'stakeholders' => 'StakeholderController', // Epic C / C5 — Registro stakeholder + matrice di Mendelow
|
||||
'stakeholder-activities' => 'StakeholderActivityController', // Epic C / C5.2 — Attività stakeholder (questionari/azioni + calendario)
|
||||
'stakeholder-portal' => 'StakeholderPortalController', // Epic C / C5.2b — Portale esterno stakeholder (token magic-link, NO JWT)
|
||||
'internal-audits' => 'InternalAuditController', // Modulo A — Audit interni ISO 27001 §9.2
|
||||
'management-reviews' => 'ManagementReviewController', // Modulo B — Riesame di Direzione ISO 27001 §9.3
|
||||
'calendar' => 'CalendarController', // Modulo C — Calendario unico scadenze (aggregatore sola lettura)
|
||||
'assessments' => 'AssessmentController',
|
||||
'isms' => 'IsmsModelController', // Modello Organizzativo SGSI (ISO 27001/27017/27018 + SoA)
|
||||
'dashboard' => 'DashboardController',
|
||||
@@ -475,6 +478,38 @@ $actionMap = [
|
||||
'POST:attachment' => 'attachment',
|
||||
],
|
||||
|
||||
// ── InternalAuditController (Modulo A — Audit interni ISO 27001 §9.2) ──
|
||||
'internal-audits' => [
|
||||
'GET:list' => 'list',
|
||||
'POST:create' => 'create',
|
||||
'POST:items' => 'addItem',
|
||||
'PUT:items/{subId}' => 'updateItem',
|
||||
'GET:{id}' => 'get',
|
||||
'PUT:{id}' => 'update',
|
||||
'DELETE:{id}' => 'delete',
|
||||
'POST:{id}/raiseNcr' => 'raiseNcr',
|
||||
'GET:{id}/report' => 'report',
|
||||
],
|
||||
|
||||
// ── ManagementReviewController — Riesame di Direzione (ISO 27001 §9.3, Modulo B) ──
|
||||
'management-reviews' => [
|
||||
'GET:list' => 'list',
|
||||
'GET:gather' => 'gather',
|
||||
'POST:create' => 'create',
|
||||
'GET:{id}' => 'get',
|
||||
'PUT:{id}' => 'update',
|
||||
'POST:{id}/decisions' => 'addDecision',
|
||||
'PUT:decisions/{subId}' => 'updateDecision',
|
||||
'POST:{id}/approve' => 'approve',
|
||||
'GET:{id}/report' => 'report',
|
||||
],
|
||||
|
||||
// ── CalendarController (Modulo C — calendario unico scadenze, sola lettura) ──
|
||||
'calendar' => [
|
||||
'GET:events' => 'events',
|
||||
'GET:summary' => 'summary',
|
||||
],
|
||||
|
||||
// ── AuditController ─────────────────────────────
|
||||
'audit' => [
|
||||
'GET:controls' => 'listControls',
|
||||
|
||||
@@ -910,8 +910,8 @@ curl -H <span class="str">"X-API-Key: nis2_TUA_CHIAVE"</span> \
|
||||
|
||||
</main>
|
||||
|
||||
<script src="js/common.js?v=20260629"></script>
|
||||
<script src="js/i18n.js?v=20260629"></script>
|
||||
<script src="js/common.js?v=20260630"></script>
|
||||
<script src="js/i18n.js?v=20260630"></script>
|
||||
<script>
|
||||
function showTab(id) {
|
||||
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Audit interni - NIS2 Agile</title>
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260630">
|
||||
<style>
|
||||
.ia-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
|
||||
.ia-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
||||
.ia-toolbar { display:flex; justify-content:flex-end; margin-bottom:12px; }
|
||||
.ia-table { width:100%; border-collapse:collapse; font-size:.88rem; }
|
||||
.ia-table th, .ia-table td { text-align:left; padding:9px 12px; border-top:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
|
||||
.ia-table th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
|
||||
.ia-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; white-space:nowrap; }
|
||||
.ia-badge.s-planned { background:#e0f2fe; color:#075985; }
|
||||
.ia-badge.s-in_progress { background:#fef3c7; color:#92400e; }
|
||||
.ia-badge.s-completed { background:#dcfce7; color:#166534; }
|
||||
.ia-badge.s-cancelled { background:#fee2e2; color:#991b1b; }
|
||||
.ia-empty { text-align:center; padding:22px 16px; color:var(--gray-500,#6b7280); font-size:.86rem; }
|
||||
.ia-overdue { color:#991b1b; font-weight:600; }
|
||||
.sm-overlay { display:none; position:fixed; inset:0; background:rgba(15,23,42,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:30px 16px; }
|
||||
.sm-overlay.open { display:flex; }
|
||||
.sm-dialog { background:#fff; border-radius:12px; width:100%; max-width:760px; box-shadow:0 20px 50px rgba(0,0,0,.25); }
|
||||
.sm-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
|
||||
.sm-head h3 { margin:0; font-size:1.05rem; }
|
||||
.sm-body { padding:18px 20px; }
|
||||
.sm-foot { padding:14px 20px; border-top:1px solid var(--gray-100,#f3f4f6); display:flex; justify-content:flex-end; gap:10px; flex-wrap:wrap; }
|
||||
.sm-field { margin-bottom:14px; }
|
||||
.sm-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; }
|
||||
.sm-field .hint { font-weight:400; color:var(--gray-500,#6b7280); font-size:.78rem; }
|
||||
.sm-field input[type=text], .sm-field input[type=date], .sm-field select, .sm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; background:#fff; }
|
||||
.sm-field textarea { min-height:60px; resize:vertical; }
|
||||
.sm-row { display:flex; gap:12px; flex-wrap:wrap; }
|
||||
.sm-row > .sm-field { flex:1 1 0; min-width:150px; }
|
||||
.sm-err { color:#b91c1c; font-size:.82rem; min-height:18px; }
|
||||
.sm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); }
|
||||
.hidden { display:none !important; }
|
||||
.ck-group { margin-top:16px; }
|
||||
.ck-group h4 { font-size:.82rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); margin:14px 0 6px; }
|
||||
.ck-row { display:flex; gap:8px; align-items:flex-start; padding:8px 0; border-top:1px solid var(--gray-100,#f3f4f6); }
|
||||
.ck-row:first-of-type { border-top:none; }
|
||||
.ck-ref { flex:0 0 64px; font-size:.78rem; font-weight:700; color:#374151; padding-top:8px; }
|
||||
.ck-main { flex:1 1 auto; }
|
||||
.ck-cp { font-size:.86rem; }
|
||||
.ck-controls { display:flex; gap:6px; align-items:center; margin-top:5px; flex-wrap:wrap; }
|
||||
.ck-controls select { padding:5px 8px; border:1px solid var(--gray-200,#e5e7eb); border-radius:7px; font-size:.8rem; }
|
||||
.ck-controls input[type=text] { padding:5px 8px; border:1px solid var(--gray-200,#e5e7eb); border-radius:7px; font-size:.8rem; flex:1 1 180px; }
|
||||
.ck-nc { font-size:.74rem; background:none; border:1px solid #fecaca; color:#b91c1c; border-radius:6px; padding:4px 9px; cursor:pointer; }
|
||||
.ck-nc[disabled] { opacity:.6; cursor:default; }
|
||||
.ck-ncbadge { font-size:.72rem; color:#991b1b; font-weight:700; }
|
||||
.det-meta { font-size:.85rem; color:#374151; margin-bottom:6px; }
|
||||
.det-meta strong { color:#111827; }
|
||||
</style>
|
||||
<!-- PWA:start -->
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#0066CC">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
|
||||
<meta name="application-name" content="NIS2 Agile">
|
||||
<script src="/js/pwa.js?v=20260614" defer></script>
|
||||
<!-- PWA:end -->
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<h2 data-i18n="iaud.title">Audit interni</h2>
|
||||
</header>
|
||||
<div class="content-body">
|
||||
<div class="ia-intro">
|
||||
<strong>Audit interni del SGSI (ISO/IEC 27001 §9.2).</strong>
|
||||
Programma gli audit, conduci la <em>checklist</em> (clausole 4-10 ISO 27001 + controlli Annex A applicabili dal tuo SoA),
|
||||
registra gli esiti riga per riga, apri una <em>non conformità</em> sui punti non conformi e genera il <em>report</em> stampabile.
|
||||
</div>
|
||||
<div class="ia-note">
|
||||
Ancoraggio: <strong>ISO/IEC 27001 §9.2</strong> (audit interni) — buona prassi di governance anche per NIS2
|
||||
(<strong>GV.PO-02</strong>, monitoraggio del programma di sicurezza). Strumento di supporto organizzativo, non un parere legale.
|
||||
</div>
|
||||
|
||||
<div class="ia-toolbar"><button class="btn btn-primary btn-sm" onclick="auditOpen()">+ Nuovo audit</button></div>
|
||||
<div id="aud-list" aria-live="polite"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modale crea/modifica audit -->
|
||||
<div class="sm-overlay" id="aud-modal" role="dialog" aria-modal="true" aria-labelledby="aud-modal-title">
|
||||
<div class="sm-dialog">
|
||||
<div class="sm-head"><h3 id="aud-modal-title">Nuovo audit</h3><button class="sm-close" type="button" aria-label="Chiudi" onclick="auditClose()">×</button></div>
|
||||
<div class="sm-body">
|
||||
<input type="hidden" id="aud-id">
|
||||
<div class="sm-field"><label for="aud-title">Titolo *</label><input type="text" id="aud-title" maxlength="255" placeholder="Es. Audit interno SGSI 2026"></div>
|
||||
<div class="sm-row">
|
||||
<div class="sm-field"><label for="aud-status">Stato</label>
|
||||
<select id="aud-status">
|
||||
<option value="planned">Pianificato</option>
|
||||
<option value="in_progress">In corso</option>
|
||||
<option value="completed">Completato</option>
|
||||
<option value="cancelled">Annullato</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm-field"><label for="aud-planned">Data pianificata <span class="hint">(→ calendario)</span></label><input type="date" id="aud-planned"></div>
|
||||
<div class="sm-field"><label for="aud-executed">Data esecuzione</label><input type="date" id="aud-executed"></div>
|
||||
</div>
|
||||
<div class="sm-field"><label for="aud-scope">Ambito (scope)</label><textarea id="aud-scope" maxlength="2000" placeholder="Es. SGSI, sede principale, processi IT"></textarea></div>
|
||||
<div class="sm-field"><label for="aud-criteria">Criteri</label><textarea id="aud-criteria" maxlength="2000" placeholder="Es. ISO/IEC 27001:2022 cl.4-10 + Annex A">ISO/IEC 27001:2022 cl.4-10 + Annex A</textarea>
|
||||
<span class="hint">Alla creazione la checklist viene pre-popolata con le clausole 4-10 e i controlli Annex A applicabili dal tuo SoA.</span>
|
||||
</div>
|
||||
<div class="sm-row">
|
||||
<div class="sm-field"><label for="aud-lead-user">Auditor capo <span class="hint">(utente)</span></label><select id="aud-lead-user"><option value="">— Nessuno —</option></select></div>
|
||||
<div class="sm-field"><label for="aud-lead-role">Auditor capo <span class="hint">(ruolo)</span></label><select id="aud-lead-role"><option value="">— Nessuno —</option></select></div>
|
||||
</div>
|
||||
<div class="sm-field" id="aud-wrap-conclusion"><label for="aud-conclusion">Conclusioni</label><textarea id="aud-conclusion" maxlength="2000"></textarea></div>
|
||||
<div class="sm-err" id="aud-err" role="alert"></div>
|
||||
</div>
|
||||
<div class="sm-foot"><button class="btn btn-outline" type="button" onclick="auditClose()">Annulla</button><button class="btn btn-primary" type="button" id="aud-save" onclick="auditSave()">Salva</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modale dettaglio audit (checklist) -->
|
||||
<div class="sm-overlay" id="det-modal" role="dialog" aria-modal="true" aria-labelledby="det-title">
|
||||
<div class="sm-dialog">
|
||||
<div class="sm-head"><h3 id="det-title">Dettaglio audit</h3><button class="sm-close" type="button" aria-label="Chiudi" onclick="detClose()">×</button></div>
|
||||
<div class="sm-body" id="det-body"></div>
|
||||
<div class="sm-foot">
|
||||
<button class="btn btn-outline" type="button" onclick="detClose()">Chiudi</button>
|
||||
<button class="btn btn-outline" type="button" onclick="detAddItem()">+ Voce custom</button>
|
||||
<button class="btn btn-outline" type="button" onclick="detEdit()">Modifica audit</button>
|
||||
<a class="btn btn-primary" id="det-report" href="#" target="_blank" rel="noopener">Report</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
/*
|
||||
* NIS2 Agile - Audit interni (ISO 27001 §9.2) — MODULO A
|
||||
* Programma audit + checklist (clausole 4-10 + Annex A dal SoA) + esiti per riga
|
||||
* + apertura NC sui non conformi + report HTML stampabile.
|
||||
*/
|
||||
let IA = { audits: [], users: [], roles: [], detail: null };
|
||||
const ST_LABELS = { planned: 'Pianificato', in_progress: 'In corso', completed: 'Completato', cancelled: 'Annullato' };
|
||||
const RES_LABELS = { da_verificare: 'Da verificare', conforme: 'Conforme', non_conforme: 'Non conforme', osservazione: 'Osservazione', opportunita: 'Opportunità', non_applicabile: 'Non applicabile' };
|
||||
const TYPE_LABELS = { clause: 'Clausole ISO 27001', annex_control: 'Controlli Annex A', nis2_measure: 'Misure NIS2', custom: 'Voci aggiuntive' };
|
||||
const TYPE_ORDER = ['clause', 'annex_control', 'nis2_measure', 'custom'];
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
||||
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
|
||||
// Il report è HTML servito dall'API: token+org passati in query (no header su <a target=_blank>).
|
||||
function reportUrl(id) {
|
||||
const base = (api && api.baseUrl) ? api.baseUrl : '/api';
|
||||
const qs = [];
|
||||
if (api && api.token) qs.push('token=' + encodeURIComponent(api.token));
|
||||
if (api && api.orgId) qs.push('org_id=' + encodeURIComponent(api.orgId));
|
||||
return base + '/internal-audits/' + id + '/report' + (qs.length ? '?' + qs.join('&') : '');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function () {
|
||||
if (typeof checkAuth === 'function' && !checkAuth()) return;
|
||||
if (window.I18n && I18n.init) I18n.init('it');
|
||||
if (typeof loadSidebar === 'function') loadSidebar();
|
||||
if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') { auditClose(); detClose(); } });
|
||||
['aud-modal', 'det-modal'].forEach(m => { const o = el(m); if (o) o.addEventListener('click', e => { if (e.target === o) o.classList.remove('open'); }); });
|
||||
await iaLoadAll();
|
||||
});
|
||||
|
||||
async function iaLoadAll() {
|
||||
el('aud-list').innerHTML = '<div class="ia-empty">Caricamento…</div>';
|
||||
try {
|
||||
const [auds, users, roles] = await Promise.all([
|
||||
api.intAuditList(),
|
||||
api.orgRolesAssignableUsers().catch(() => ({ users: [] })),
|
||||
api.orgRolesList().catch(() => ({ roles: [] }))
|
||||
]);
|
||||
IA.audits = (auds && auds.audits) || [];
|
||||
IA.users = (users && (users.users || users.assignable_users)) || [];
|
||||
IA.roles = (roles && (roles.roles || roles.org_roles)) || [];
|
||||
fillSelects();
|
||||
renderAudits();
|
||||
} catch (e) {
|
||||
el('aud-list').innerHTML = '<div class="ia-empty">Errore: ' + esc(e.message || e) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function fillSelects() {
|
||||
el('aud-lead-user').innerHTML = '<option value="">— Nessuno —</option>'
|
||||
+ IA.users.map(u => '<option value="' + u.id + '">' + esc(u.full_name || u.name || ('Utente ' + u.id)) + '</option>').join('');
|
||||
el('aud-lead-role').innerHTML = '<option value="">— Nessuno —</option>'
|
||||
+ IA.roles.map(r => '<option value="' + r.id + '">' + esc(r.role_name || r.name || ('Ruolo ' + r.id)) + '</option>').join('');
|
||||
}
|
||||
|
||||
function todayStr() { return new Date().toISOString().slice(0, 10); }
|
||||
function isOverdue(a) { return a.planned_date && a.planned_date < todayStr() && a.status !== 'completed' && a.status !== 'cancelled'; }
|
||||
|
||||
function renderAudits() {
|
||||
if (!IA.audits.length) { el('aud-list').innerHTML = '<div class="ia-empty">Nessun audit. Creane uno con "+ Nuovo audit".</div>'; return; }
|
||||
const rows = IA.audits.map(a => {
|
||||
const dl = a.planned_date ? ('<span class="' + (isOverdue(a) ? 'ia-overdue' : '') + '">' + esc(a.planned_date) + '</span>') : '—';
|
||||
const lead = a.lead_auditor_name ? esc(a.lead_auditor_name) : (a.lead_auditor_role_name ? esc(a.lead_auditor_role_name) : '—');
|
||||
const ncTag = a.n_nc > 0 ? ' <span class="ia-badge s-cancelled">' + a.n_nc + ' NC</span>' : '';
|
||||
return '<tr>'
|
||||
+ '<td><strong>' + esc(a.code || '—') + '</strong></td>'
|
||||
+ '<td><a href="#" onclick="detOpen(' + a.id + ');return false;"><strong>' + esc(a.title) + '</strong></a><div style="font-size:.78rem;color:#6b7280;">' + a.n_items + ' voci' + ncTag + '</div></td>'
|
||||
+ '<td><span class="ia-badge s-' + a.status + '">' + (ST_LABELS[a.status] || a.status) + '</span></td>'
|
||||
+ '<td>' + lead + '</td>'
|
||||
+ '<td>' + dl + '</td>'
|
||||
+ '<td style="white-space:nowrap;">'
|
||||
+ '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="detOpen(' + a.id + ')">Apri</button> '
|
||||
+ '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;color:#b91c1c;border-color:#fecaca;" onclick="auditDelete(' + a.id + ')">Elimina</button>'
|
||||
+ '</td></tr>';
|
||||
}).join('');
|
||||
el('aud-list').innerHTML = '<table class="ia-table"><thead><tr><th>Codice</th><th>Titolo</th><th>Stato</th><th>Auditor capo</th><th>Scadenza</th><th>Azioni</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
// ── Crea / modifica audit ──
|
||||
function auditOpen(id) {
|
||||
el('aud-err').textContent = '';
|
||||
el('aud-id').value = id || '';
|
||||
el('aud-modal-title').textContent = id ? 'Modifica audit' : 'Nuovo audit';
|
||||
el('aud-wrap-conclusion').classList.toggle('hidden', !id);
|
||||
if (!id) {
|
||||
el('aud-title').value = ''; el('aud-status').value = 'planned';
|
||||
el('aud-planned').value = ''; el('aud-executed').value = '';
|
||||
el('aud-scope').value = ''; el('aud-criteria').value = 'ISO/IEC 27001:2022 cl.4-10 + Annex A';
|
||||
el('aud-lead-user').value = ''; el('aud-lead-role').value = ''; el('aud-conclusion').value = '';
|
||||
} else {
|
||||
const a = IA.detail || IA.audits.find(x => x.id === id) || {};
|
||||
el('aud-title').value = a.title || ''; el('aud-status').value = a.status || 'planned';
|
||||
el('aud-planned').value = a.planned_date || ''; el('aud-executed').value = a.executed_date || '';
|
||||
el('aud-scope').value = a.scope || ''; el('aud-criteria').value = a.criteria || '';
|
||||
el('aud-lead-user').value = a.lead_auditor_user_id || ''; el('aud-lead-role').value = a.lead_auditor_role_id || '';
|
||||
el('aud-conclusion').value = a.conclusion || '';
|
||||
}
|
||||
el('aud-modal').classList.add('open');
|
||||
setTimeout(function () { var fe = el('aud-modal').querySelector('input,select,textarea'); if (fe) fe.focus(); }, 50);
|
||||
}
|
||||
function auditClose() { el('aud-modal').classList.remove('open'); }
|
||||
|
||||
async function auditSave() {
|
||||
const id = el('aud-id').value;
|
||||
const title = el('aud-title').value.trim();
|
||||
if (!title) { el('aud-err').textContent = 'Il titolo è obbligatorio.'; return; }
|
||||
const payload = {
|
||||
title: title,
|
||||
status: el('aud-status').value,
|
||||
planned_date: el('aud-planned').value || null,
|
||||
executed_date: el('aud-executed').value || null,
|
||||
scope: el('aud-scope').value.trim() || null,
|
||||
criteria: el('aud-criteria').value.trim() || null,
|
||||
lead_auditor_user_id: el('aud-lead-user').value || null,
|
||||
lead_auditor_role_id: el('aud-lead-role').value || null
|
||||
};
|
||||
if (id) { payload.conclusion = el('aud-conclusion').value.trim() || null; }
|
||||
el('aud-save').disabled = true;
|
||||
try {
|
||||
if (id) {
|
||||
await api.intAuditUpdate(id, payload);
|
||||
} else {
|
||||
const res = await api.intAuditCreate(payload);
|
||||
if (res && res.items_seeded != null) { /* checklist pre-popolata */ }
|
||||
}
|
||||
auditClose();
|
||||
await iaLoadAll();
|
||||
} catch (e) {
|
||||
el('aud-err').textContent = e.message || 'Errore di salvataggio.';
|
||||
} finally {
|
||||
el('aud-save').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function auditDelete(id) {
|
||||
if (!confirm('Eliminare questo audit e tutta la sua checklist?')) return;
|
||||
try { await api.intAuditDelete(id); await iaLoadAll(); }
|
||||
catch (e) { alert(e.message || 'Errore eliminazione.'); }
|
||||
}
|
||||
|
||||
// ── Dettaglio (checklist) ──
|
||||
async function detOpen(id) {
|
||||
el('det-body').innerHTML = '<div class="ia-empty">Caricamento…</div>';
|
||||
el('det-modal').classList.add('open');
|
||||
setTimeout(function () { var fe = el('det-modal').querySelector('.sm-close, button'); if (fe) fe.focus(); }, 50);
|
||||
try {
|
||||
const a = await api.intAuditGet(id);
|
||||
IA.detail = a;
|
||||
el('det-report').href = reportUrl(id);
|
||||
renderDetail(a);
|
||||
} catch (e) {
|
||||
el('det-body').innerHTML = '<div class="ia-empty">Errore: ' + esc(e.message || e) + '</div>';
|
||||
}
|
||||
}
|
||||
function detClose() { el('det-modal').classList.remove('open'); IA.detail = null; }
|
||||
function detEdit() { if (IA.detail) auditOpen(IA.detail.id); }
|
||||
|
||||
function renderDetail(a) {
|
||||
el('det-title').textContent = (a.code ? a.code + ' — ' : '') + a.title;
|
||||
const lead = a.lead_auditor_name || a.lead_auditor_role_name || '—';
|
||||
let h = '<div class="det-meta"><strong>Stato:</strong> ' + esc(ST_LABELS[a.status] || a.status)
|
||||
+ ' · <strong>Auditor capo:</strong> ' + esc(lead)
|
||||
+ ' · <strong>Pianificata:</strong> ' + esc(a.planned_date || '—') + '</div>';
|
||||
if (a.scope) h += '<div class="det-meta"><strong>Ambito:</strong> ' + esc(a.scope) + '</div>';
|
||||
if (a.criteria) h += '<div class="det-meta"><strong>Criteri:</strong> ' + esc(a.criteria) + '</div>';
|
||||
|
||||
const items = a.items || [];
|
||||
if (!items.length) { h += '<div class="ia-empty">Nessuna voce di checklist. Aggiungine una con "+ Voce custom".</div>'; el('det-body').innerHTML = h; return; }
|
||||
|
||||
const groups = {};
|
||||
items.forEach(it => { (groups[it.ref_type] = groups[it.ref_type] || []).push(it); });
|
||||
TYPE_ORDER.forEach(type => {
|
||||
if (!groups[type] || !groups[type].length) return;
|
||||
h += '<div class="ck-group"><h4>' + esc(TYPE_LABELS[type] || type) + ' (' + groups[type].length + ')</h4>';
|
||||
groups[type].forEach(it => { h += renderItemRow(it); });
|
||||
h += '</div>';
|
||||
});
|
||||
el('det-body').innerHTML = h;
|
||||
}
|
||||
|
||||
function renderItemRow(it) {
|
||||
const opts = Object.keys(RES_LABELS).map(k => '<option value="' + k + '"' + (it.result === k ? ' selected' : '') + '>' + esc(RES_LABELS[k]) + '</option>').join('');
|
||||
let ncCtl;
|
||||
if (it.ncr) {
|
||||
ncCtl = '<span class="ck-ncbadge">NC ' + esc(it.ncr.ncr_code) + '</span>';
|
||||
} else {
|
||||
const dis = it.result === 'non_conforme' ? '' : ' disabled title="Apri NC disponibile sulle voci non conformi"';
|
||||
ncCtl = '<button class="ck-nc" onclick="itemRaiseNcr(' + it.id + ')"' + dis + '>Apri NC</button>';
|
||||
}
|
||||
return '<div class="ck-row" id="ck-' + it.id + '">'
|
||||
+ '<div class="ck-ref">' + esc(it.ref_code || '') + '</div>'
|
||||
+ '<div class="ck-main">'
|
||||
+ '<div class="ck-cp">' + esc(it.checkpoint) + '</div>'
|
||||
+ '<div class="ck-controls">'
|
||||
+ '<select onchange="itemSetResult(' + it.id + ', this.value)">' + opts + '</select>'
|
||||
+ '<input type="text" placeholder="Note / evidenze" value="' + escAttr(it.note || '') + '" onchange="itemSetNote(' + it.id + ', this.value)">'
|
||||
+ ncCtl
|
||||
+ '</div></div></div>';
|
||||
}
|
||||
|
||||
async function itemSetResult(itemId, value) {
|
||||
try {
|
||||
await api.intAuditUpdateItem(itemId, { result: value });
|
||||
const it = findItem(itemId); if (it) { it.result = value; }
|
||||
// ridisegna la riga per aggiornare il pulsante "Apri NC"
|
||||
const row = el('ck-' + itemId); if (row && it) row.outerHTML = renderItemRow(it);
|
||||
} catch (e) { alert(e.message || 'Errore aggiornamento esito.'); }
|
||||
}
|
||||
async function itemSetNote(itemId, value) {
|
||||
try { await api.intAuditUpdateItem(itemId, { note: value }); const it = findItem(itemId); if (it) it.note = value; }
|
||||
catch (e) { alert(e.message || 'Errore aggiornamento nota.'); }
|
||||
}
|
||||
async function itemRaiseNcr(itemId) {
|
||||
if (!IA.detail) return;
|
||||
if (!confirm('Aprire una non conformità (NC) collegata a questa voce?')) return;
|
||||
try {
|
||||
const res = await api.intAuditRaiseNcr(IA.detail.id, { item_id: itemId });
|
||||
const it = findItem(itemId);
|
||||
if (it) { it.ncr = { id: res.id, ncr_code: res.ncr_code }; const row = el('ck-' + itemId); if (row) row.outerHTML = renderItemRow(it); }
|
||||
alert('Non conformità ' + (res.ncr_code || '') + (res.already ? ' (già esistente)' : ' creata') + '. La trovi nel modulo NCR/CAPA.');
|
||||
await iaLoadAll();
|
||||
} catch (e) { alert(e.message || 'Errore apertura NC.'); }
|
||||
}
|
||||
function findItem(itemId) { return (IA.detail && IA.detail.items || []).find(x => x.id === itemId); }
|
||||
|
||||
async function detAddItem() {
|
||||
if (!IA.detail) return;
|
||||
const cp = prompt('Testo del punto di verifica (voce custom):');
|
||||
if (!cp || !cp.trim()) return;
|
||||
try {
|
||||
await api.intAuditAddItem({ audit_id: IA.detail.id, checkpoint: cp.trim(), ref_type: 'custom' });
|
||||
await detOpen(IA.detail.id);
|
||||
await iaLoadAll();
|
||||
} catch (e) { alert(e.message || 'Errore aggiunta voce.'); }
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+5
-5
@@ -184,8 +184,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -195,9 +195,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script src="/js/isms.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -221,6 +221,7 @@ class NIS2API {
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
orgRolesList() { return this._acn(this.get('/org-roles/list')); }
|
||||
orgRolesAssignableUsers() { return this._acn(this.get('/org-roles/assignable-users')); }
|
||||
orgMembers() { return this._acn(this.get('/organizations/' + (this.orgId || '') + '/members')); }
|
||||
orgRoleGet(id) { return this._acn(this.get(`/org-roles/${id}`)); }
|
||||
orgRoleCreate(data) { return this._acn(this.post('/org-roles/create', data || {})); }
|
||||
orgRoleUpdate(id, data) { return this._acn(this.put(`/org-roles/${id}`, data)); }
|
||||
@@ -333,6 +334,43 @@ class NIS2API {
|
||||
stkActAddComment(id, d) { return this._acn(this.post(`/stakeholder-activities/${id}/comments`, d || {})); }
|
||||
stkActAttachments(id) { return this._acn(this.get(`/stakeholder-activities/${id}/attachments`)); }
|
||||
|
||||
// ── Audit interni (Modulo A — ISO 27001 §9.2) ──
|
||||
intAuditList() { return this._acn(this.get('/internal-audits/list')); }
|
||||
intAuditGet(id) { return this._acn(this.get(`/internal-audits/${id}`)); }
|
||||
intAuditCreate(data) { return this._acn(this.post('/internal-audits/create', data || {})); }
|
||||
intAuditUpdate(id, data) { return this._acn(this.put(`/internal-audits/${id}`, data)); }
|
||||
intAuditDelete(id) { return this._acn(this.del(`/internal-audits/${id}`)); }
|
||||
intAuditAddItem(data) { return this._acn(this.post('/internal-audits/items', data || {})); }
|
||||
intAuditUpdateItem(itemId, data) { return this._acn(this.put(`/internal-audits/items/${itemId}`, data)); }
|
||||
intAuditRaiseNcr(id, data) { return this._acn(this.post(`/internal-audits/${id}/raise-ncr`, data || {})); }
|
||||
|
||||
// ── Riesame di Direzione (ISO 27001 §9.3, Modulo B) ──
|
||||
mgmtReviewList() { return this._acn(this.get('/management-reviews/list')); }
|
||||
mgmtReviewGet(id) { return this._acn(this.get(`/management-reviews/${id}`)); }
|
||||
mgmtReviewCreate(data) { return this._acn(this.post('/management-reviews/create', data || {})); }
|
||||
mgmtReviewUpdate(id, data) { return this._acn(this.put(`/management-reviews/${id}`, data || {})); }
|
||||
mgmtReviewGather() { return this._acn(this.get('/management-reviews/gather')); }
|
||||
mgmtReviewAddDecision(id, data) { return this._acn(this.post(`/management-reviews/${id}/decisions`, data || {})); }
|
||||
mgmtReviewUpdateDecision(subId, data) { return this._acn(this.put(`/management-reviews/decisions/${subId}`, data || {})); }
|
||||
mgmtReviewApprove(id) { return this._acn(this.post(`/management-reviews/${id}/approve`, {})); }
|
||||
|
||||
// ── Calendario unico scadenze (Modulo C) — aggregatore sola lettura ──
|
||||
calendarEvents(params) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params && params.from) qs.set('from', params.from);
|
||||
if (params && params.to) qs.set('to', params.to);
|
||||
if (params && params.types) qs.set('types', Array.isArray(params.types) ? params.types.join(',') : params.types);
|
||||
const q = qs.toString();
|
||||
return this._acn(this.get('/calendar/events' + (q ? '?' + q : '')));
|
||||
}
|
||||
calendarSummary(params) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params && params.from) qs.set('from', params.from);
|
||||
if (params && params.to) qs.set('to', params.to);
|
||||
const q = qs.toString();
|
||||
return this._acn(this.get('/calendar/summary' + (q ? '?' + q : '')));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Dashboard
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
label: 'Principale', i18nKey: 'nav.main',
|
||||
items: [
|
||||
{ name: 'Dashboard', href: 'dashboard.html', icon: iconGrid(), i18nKey: 'nav.dashboard' },
|
||||
{ name: 'Calendario', href: 'calendario.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.calendar' },
|
||||
{ name: 'Compliance Journey', href: 'workflow.html', icon: '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/></svg>' },
|
||||
{ name: 'Gap Analysis', href: 'assessment.html', icon: iconClipboardCheck(), i18nKey: 'nav.gap_analysis' },
|
||||
{ name: 'Misure e Requisiti', href: 'misure-requisiti.html', icon: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h8a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>', i18nKey: 'nav.framework' },
|
||||
@@ -90,6 +91,8 @@
|
||||
items: [
|
||||
{ name: 'Formazione', href: 'training.html', icon: iconAcademicCap(), i18nKey: 'nav.training' },
|
||||
{ name: 'Inventario', href: 'assets.html', icon: iconServer(), i18nKey: 'nav.assets' },
|
||||
{ name: 'Audit interni', href: 'internal-audits.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm5.707 6.707a1 1 0 00-1.414-1.414L7 10.586l-.293-.293a1 1 0 00-1.414 1.414l1 1a1 1 0 001.414 0l2-2zM10 13a1 1 0 100 2h3a1 1 0 100-2h-3z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.internal_audits' },
|
||||
{ name: 'Riesame di Direzione', href: 'management-review.html', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>`, i18nKey: 'nav.management_review' },
|
||||
{ name: 'Audit & Report', href: 'reports.html', icon: iconChartBar(), i18nKey: 'nav.audit' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -186,6 +186,7 @@ function loadSidebar() {
|
||||
label: 'Principale', i18nKey: 'nav.main',
|
||||
items: [
|
||||
{ name: 'Dashboard', href: 'dashboard.html', icon: iconGrid(), i18nKey: 'nav.dashboard' },
|
||||
{ name: 'Calendario', href: 'calendario.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.calendar' },
|
||||
{ name: 'Compliance Journey', href: 'workflow.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/></svg>` },
|
||||
{ name: 'Gap Analysis', href: 'assessment.html', icon: iconClipboardCheck(), i18nKey: 'nav.gap_analysis' },
|
||||
{ name: 'Misure e Requisiti', href: 'misure-requisiti.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h8a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.framework' },
|
||||
@@ -216,6 +217,8 @@ function loadSidebar() {
|
||||
items: [
|
||||
{ name: 'Formazione', href: 'training.html', icon: iconAcademicCap(), i18nKey: 'nav.training' },
|
||||
{ name: 'Inventario', href: 'assets.html', icon: iconServer(), i18nKey: 'nav.assets' },
|
||||
{ name: 'Audit interni', href: 'internal-audits.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm5.707 6.707a1 1 0 00-1.414-1.414L7 10.586l-.293-.293a1 1 0 00-1.414 1.414l1 1a1 1 0 001.414 0l2-2zM10 13a1 1 0 100 2h3a1 1 0 100-2h-3z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.internal_audits' },
|
||||
{ name: 'Riesame di Direzione', href: 'management-review.html', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>`, i18nKey: 'nav.management_review' },
|
||||
{ name: 'Audit & Report',href: 'reports.html', icon: iconChartBar(), i18nKey: 'nav.audit' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -400,6 +400,120 @@ const HelpSystem = (function () {
|
||||
'NOTA: gli obblighi normativi in Italia derivano dal D.Lgs. 138/2024; la matrice e i framework NIST CSF/ACN sono strumenti di supporto, non un parere legale.'
|
||||
]
|
||||
},
|
||||
'internal-audits': {
|
||||
title: 'Guida - Audit interni',
|
||||
intro: 'Il modulo Audit interni gestisce il ciclo degli audit del SGSI previsto dalla clausola 9.2 della ISO/IEC 27001: programmi gli audit, conduci la checklist (clausole 4-10 ISO 27001 + controlli Annex A applicabili dal tuo SoA), registri gli esiti riga per riga, apri una non conformità sui punti non conformi e generi il report stampabile. È uno strumento di supporto organizzativo, non un parere legale.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Programma audit',
|
||||
items: [
|
||||
'Crea un <strong>audit</strong> indicando titolo, ambito (scope), criteri (es. "ISO/IEC 27001:2022 cl.4-10 + Annex A"), data pianificata, data di esecuzione e auditor capo (un utente dell\'organizzazione oppure un ruolo dell\'organigramma).',
|
||||
'Il sistema assegna automaticamente un <strong>codice progressivo</strong> (AUD-001, AUD-002, …) per organizzazione.',
|
||||
'La <strong>data pianificata</strong> compare automaticamente nel <strong>calendario NIS2</strong> (Scadenziario).'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Checklist di conduzione',
|
||||
items: [
|
||||
'Alla creazione la checklist viene <strong>pre-popolata</strong>: le <strong>clausole 4-10</strong> della ISO 27001 e i <strong>controlli Annex A</strong> marcati come applicabili nel tuo <strong>SoA</strong> (Modello SGSI). Parti già con la checklist, non da foglio bianco.',
|
||||
'Per ogni voce scegli l\'<strong>esito</strong> (Da verificare, Conforme, Non conforme, Osservazione, Opportunità, Non applicabile) e annoti note/evidenze; il salvataggio è immediato.',
|
||||
'Puoi aggiungere <strong>voci custom</strong> non previste dalle clausole o dall\'Annex A.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Non conformità e report',
|
||||
items: [
|
||||
'Sulle voci marcate <strong>Non conforme</strong> puoi premere <strong>Apri NC</strong>: il sistema crea una non conformità collegata (sorgente "audit") che confluisce nel modulo <strong>NCR/CAPA</strong>, dove ne gestisci root cause e azioni correttive.',
|
||||
'Il pulsante <strong>Report</strong> apre un verbale di audit <strong>stampabile</strong> (HTML) con metadati, sintesi degli esiti, checklist completa e conclusioni: usa "Stampa / Salva PDF" del browser.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Riferimenti normativi',
|
||||
items: [
|
||||
'<strong>ISO/IEC 27001:2022 §9.2</strong>: l\'organizzazione conduce audit interni a intervalli pianificati per verificare che il SGSI sia conforme ai requisiti e attuato efficacemente.',
|
||||
'<strong>NIST CSF 2.0 / GV.PO-02</strong>: la politica di sicurezza e i programmi di gestione sono rivisti e migliorati nel tempo (buona prassi anche per NIS2).',
|
||||
'Le <strong>azioni</strong> correttive/preventive si gestiscono nel modulo NCR/CAPA.'
|
||||
]
|
||||
}
|
||||
],
|
||||
references: [
|
||||
'ISO/IEC 27001:2022 - Clausola 9.2 Audit interni',
|
||||
'ISO/IEC 27001:2022 - Clausole 4-10 e Annex A (controlli del SoA)',
|
||||
'NIST CSF 2.0 / GV.PO-02 - Revisione e miglioramento del programma di sicurezza',
|
||||
'Gli audit interni e il riesame periodico sono buona prassi di governance; gli obblighi normativi in Italia derivano dal D.Lgs. 138/2024.',
|
||||
'NOTA: strumento di supporto organizzativo, non un parere legale.'
|
||||
]
|
||||
},
|
||||
'management-review': {
|
||||
title: 'Guida - Riesame di Direzione (ISO 27001 §9.3)',
|
||||
intro: 'Il Riesame di Direzione e il momento in cui la direzione valuta periodicamente l\'idoneita, l\'adeguatezza e l\'efficacia del Sistema di Gestione della Sicurezza delle Informazioni (SGSI). Il modulo produce il VERBALE con gli elementi in ingresso (aggregati automaticamente dai moduli esistenti) e in uscita (le decisioni). E\' uno strumento di SUPPORTO e PRE-AUDIT: non sostituisce l\'auditor ne costituisce una certificazione.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Come si compila un verbale',
|
||||
items: [
|
||||
'<strong>1. Crea il riesame</strong>: indica data, periodo di riferimento (es. "2026 H1"), presidente e partecipanti. Il codice RD-AAAA-NN viene generato automaticamente.',
|
||||
'<strong>2. Aggrega i dati</strong>: il pulsante "Aggrega dati" raccoglie gli elementi in ingresso §9.3.2 da tutta la piattaforma (non conformita, audit interni, rischi, SoA, obiettivi, formazione, stakeholder, normativa, scadenze).',
|
||||
'<strong>3. Rivedi e completa</strong>: gli input aggregati sono una proposta da rivedere; aggiungi le conclusioni della direzione.',
|
||||
'<strong>4. Registra le decisioni</strong>: ogni decisione (elemento in uscita §9.3.3) ha un responsabile (ruolo dell\'organigramma), una scadenza e uno stato. Puo essere collegata a un\'azione CAPA.',
|
||||
'<strong>5. Approva e congela</strong>: l\'approvazione (riservata all\'org_admin) salva uno snapshot immutabile degli input al momento del verbale e blocca ogni ulteriore modifica.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Elementi in ingresso aggregati automaticamente',
|
||||
items: [
|
||||
'Le sezioni vengono lette in modo difensivo: se un modulo non e ancora attivo (es. Audit interni) o non ha dati, la sezione mostra "dato non disponibile" senza bloccare le altre.',
|
||||
'Tutti i dati sono filtrati per la tua organizzazione e fotografati nello snapshot al momento dell\'approvazione, cosi il verbale resta coerente nel tempo.',
|
||||
'Dal verbale puoi aprire la versione stampabile (pulsante "Verbale") da archiviare o presentare in audit.'
|
||||
]
|
||||
}
|
||||
],
|
||||
references: [
|
||||
'ISO/IEC 27001:2022 §9.3 - Riesame di direzione (elementi in ingresso 9.3.2 / in uscita 9.3.3)',
|
||||
'D.Lgs. 138/2024 art. 23 - Obblighi di governance degli organi di amministrazione e direttivi (NIS2 GV.PO-02)',
|
||||
'NOTA: ISO e best practice non vincolante; gli obblighi italiani derivano da NIS2 / D.Lgs. 138/2024 / Determinazioni ACN.'
|
||||
]
|
||||
},
|
||||
'calendario': {
|
||||
title: 'Guida - Calendario scadenze',
|
||||
intro: 'Il Calendario raccoglie in un unico posto ogni scadenza del sistema: incidenti Art.23, revisioni di policy e controlli, trattamenti di rischio, non conformita e azioni correttive, formazione, attivita stakeholder, scadenziario delle revisioni periodiche, audit interni e decisioni del riesame di direzione. E\' un aggregatore in SOLA LETTURA, sempre coerente con i moduli sorgente: ogni scadenza viene calcolata in tempo reale e rimanda direttamente all\'elemento che la genera. E\' uno strumento di supporto operativo, non un parere legale.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Le viste',
|
||||
items: [
|
||||
'<strong>Griglia mensile</strong>: ogni giorno mostra dei pallini colorati, uno per scadenza. Il colore indica il tipo, mentre la navigazione ‹ / › sposta il mese e "Oggi" torna al mese corrente. Clic su un giorno apre l\'elenco delle sue scadenze.',
|
||||
'<strong>Lista</strong>: tutte le scadenze ordinate per data, con quelle <strong>in ritardo</strong> in cima. Clic sul nome apre l\'elemento sorgente (incidente, policy, rischio, ...).'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Stato delle scadenze',
|
||||
items: [
|
||||
'<strong>In ritardo</strong> (rosso): la data e\' gia\' passata e la scadenza non risulta assolta.',
|
||||
'<strong>In scadenza</strong> (arancione): mancano 14 giorni o meno.',
|
||||
'<strong>Futura</strong> (blu): oltre 14 giorni.',
|
||||
'<strong>Assolta</strong> (verde): la scadenza e\' stata chiusa/completata nel modulo di origine.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Filtri',
|
||||
items: [
|
||||
'Puoi filtrare per <strong>tipo</strong> (incidenti, policy, rischi, controlli, NC/CAPA, formazione, stakeholder, revisioni, audit interni, riesame) e per <strong>stato</strong>, combinando le due dimensioni.',
|
||||
'I conteggi in alto (in ritardo / in scadenza / future / assolte) danno il colpo d\'occhio complessivo sul periodo caricato.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Da dove arrivano le scadenze',
|
||||
items: [
|
||||
'Il calendario non duplica i dati: legge le scadenze direttamente dai moduli. Per modificarne una, apri l\'elemento dal link e aggiornala nel suo modulo (es. la data di revisione di una policy si cambia in Policy).',
|
||||
'Le scadenze dei moduli Audit interni e Riesame di direzione compaiono automaticamente non appena quei moduli sono attivi.'
|
||||
]
|
||||
}
|
||||
],
|
||||
references: [
|
||||
'ISO/IEC 27001:2022, cl. 9.1 (monitoraggio e misurazione) e 9.3 (riesame di direzione) - buona prassi',
|
||||
'NIST CSF 2.0 / GV.PO-02, GV.SC-07, DE.CM (best practice) - revisione periodica e monitoraggio continuo',
|
||||
'NOTA: il calendario e\' uno strumento di supporto al monitoraggio delle scadenze; gli obblighi normativi derivano da NIS2 (Dir. UE 2022/2555) e dal D.Lgs. 138/2024.'
|
||||
]
|
||||
},
|
||||
'stakeholder-activities': {
|
||||
title: 'Guida - Attività stakeholder',
|
||||
intro: 'Le attività verso gli stakeholder ti permettono di inviare questionari (da compilare o per firma di avvenuta lettura) e pianificare azioni, collegandoli alle procedure e alle misure/requisiti, con data e scadenza sul calendario NIS2. Strumento di supporto organizzativo, non un parere legale.',
|
||||
@@ -1283,6 +1397,12 @@ const HelpSystem = (function () {
|
||||
'stakeholders.html': 'stakeholders',
|
||||
'stakeholders': 'stakeholders',
|
||||
'stakeholder-activities.html': 'stakeholder-activities',
|
||||
'internal-audits.html': 'internal-audits',
|
||||
'internal-audits': 'internal-audits',
|
||||
'management-review.html': 'management-review',
|
||||
'management-review': 'management-review',
|
||||
'calendario.html': 'calendario',
|
||||
'calendario': 'calendario',
|
||||
'stakeholder-activities': 'stakeholder-activities',
|
||||
'risks.html': 'risks',
|
||||
'risks': 'risks',
|
||||
|
||||
@@ -76,6 +76,12 @@ const I18n = (function () {
|
||||
'rev.title': { it: 'Scadenziario', en: 'Review Schedule' },
|
||||
'stk.title': { it: 'Stakeholder', en: 'Stakeholders' },
|
||||
'sact.title': { it: 'Attività stakeholder', en: 'Stakeholder activities' },
|
||||
'nav.calendar': { it: 'Calendario', en: 'Calendar' },
|
||||
'cal.title': { it: 'Calendario scadenze', en: 'Deadlines calendar' },
|
||||
'nav.internal_audits': { it: 'Audit interni', en: 'Internal audits' },
|
||||
'iaud.title': { it: 'Audit interni', en: 'Internal audits' },
|
||||
'nav.management_review': { it: 'Riesame di Direzione', en: 'Management Review' },
|
||||
'mreview.title': { it: 'Riesame di Direzione', en: 'Management Review' },
|
||||
'nav.risks': { it: 'Rischi', en: 'Risks' },
|
||||
'nav.incidents': { it: 'Incidenti', en: 'Incidents' },
|
||||
'nav.policies': { it: 'Policy', en: 'Policies' },
|
||||
|
||||
+5
-5
@@ -151,8 +151,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Stessa logica JS della pagina live (parita' funzionale assoluta, zero backend). -->
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. -->
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
@@ -161,9 +161,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script src="/js/kb.js"></script>
|
||||
<script>
|
||||
// Gate auth + chrome come le altre pagine -bi.
|
||||
|
||||
@@ -515,7 +515,7 @@ body { background: var(--bg-main); }
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script src="js/api.js?v=20260629"></script>
|
||||
<script src="js/api.js?v=20260630"></script>
|
||||
<script>
|
||||
// ══════════════════════════════════════════════════════
|
||||
// CONFIG
|
||||
|
||||
+2
-2
@@ -94,8 +94,8 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della login attuale: ZERO modifiche backend -->
|
||||
<script src="js/api.js?v=20260629"></script>
|
||||
<script src="js/common.js?v=20260629"></script>
|
||||
<script src="js/api.js?v=20260630"></script>
|
||||
<script src="js/common.js?v=20260630"></script>
|
||||
<script>
|
||||
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
|
||||
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Riesame di Direzione - NIS2 Agile</title>
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260630">
|
||||
<style>
|
||||
.mr-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
|
||||
.mr-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
||||
.mr-toolbar { display:flex; justify-content:flex-end; margin-bottom:12px; }
|
||||
.mr-table { width:100%; border-collapse:collapse; font-size:.88rem; }
|
||||
.mr-table th, .mr-table td { text-align:left; padding:9px 12px; border-top:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
|
||||
.mr-table th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
|
||||
.mr-table tr.click { cursor:pointer; } .mr-table tr.click:hover { background:#f8fafc; }
|
||||
.mr-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; white-space:nowrap; }
|
||||
.mr-badge.s-draft { background:#f3f4f6; color:#6b7280; }
|
||||
.mr-badge.s-approved { background:#dcfce7; color:#166534; }
|
||||
.mr-empty { text-align:center; padding:22px 16px; color:var(--gray-500,#6b7280); font-size:.86rem; }
|
||||
.sm-overlay { display:none; position:fixed; inset:0; background:rgba(15,23,42,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:30px 16px; }
|
||||
.sm-overlay.open { display:flex; }
|
||||
.sm-dialog { background:#fff; border-radius:12px; width:100%; max-width:820px; box-shadow:0 20px 50px rgba(0,0,0,.25); }
|
||||
.sm-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
|
||||
.sm-head h3 { margin:0; font-size:1.05rem; }
|
||||
.sm-body { padding:18px 20px; }
|
||||
.sm-foot { padding:14px 20px; border-top:1px solid var(--gray-100,#f3f4f6); display:flex; justify-content:flex-end; gap:10px; flex-wrap:wrap; }
|
||||
.sm-field { margin-bottom:14px; }
|
||||
.sm-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; }
|
||||
.sm-field .hint { font-weight:400; color:var(--gray-500,#6b7280); font-size:.78rem; }
|
||||
.sm-field input[type=text], .sm-field input[type=date], .sm-field select, .sm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; background:#fff; }
|
||||
.sm-field textarea { min-height:60px; resize:vertical; }
|
||||
.sm-row { display:flex; gap:12px; flex-wrap:wrap; }
|
||||
.sm-row > .sm-field { flex:1 1 0; min-width:150px; }
|
||||
.sm-err { color:#b91c1c; font-size:.82rem; min-height:18px; }
|
||||
.sm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); }
|
||||
.hidden { display:none !important; }
|
||||
.mr-section { border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; margin-bottom:10px; }
|
||||
.mr-section h4 { margin:0; padding:10px 14px; font-size:.86rem; background:#f8fafc; border-bottom:1px solid var(--gray-100,#f3f4f6); }
|
||||
.mr-section .body { padding:10px 14px; font-size:.84rem; }
|
||||
.mr-section .body ul { margin:6px 0 0; padding-left:18px; }
|
||||
.mr-section.na h4 { color:#9ca3af; }
|
||||
.mr-pill { display:inline-block; background:#eef2ff; color:#3730a3; font-size:.72rem; font-weight:700; padding:1px 8px; border-radius:6px; margin-left:6px; }
|
||||
.dec-row { display:flex; gap:8px; align-items:flex-start; margin-bottom:8px; flex-wrap:wrap; }
|
||||
.dec-row .grow { flex:1 1 200px; } .dec-row select, .dec-row input[type=date] { padding:7px 9px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.84rem; }
|
||||
.dec-del { background:none; border:1px solid #fecaca; color:#b91c1c; border-radius:6px; padding:6px 9px; cursor:pointer; }
|
||||
.dec-stat { font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; }
|
||||
.dec-open { background:#f3f4f6; color:#6b7280; } .dec-in_progress { background:#fef3c7; color:#92400e; } .dec-done { background:#dcfce7; color:#166534; }
|
||||
</style>
|
||||
<!-- PWA:start -->
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#0066CC">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
|
||||
<meta name="application-name" content="NIS2 Agile">
|
||||
<script src="/js/pwa.js?v=20260614" defer></script>
|
||||
<!-- PWA:end -->
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<h2 data-i18n="mreview.title">Riesame di Direzione</h2>
|
||||
</header>
|
||||
<div class="content-body">
|
||||
<div class="mr-intro">
|
||||
<strong>Riesame periodico del SGSI da parte della direzione.</strong>
|
||||
Produce il <em>verbale</em> con gli elementi in ingresso (aggregati automaticamente dai moduli esistenti) e in uscita
|
||||
(le <em>decisioni</em>: azioni con responsabile e scadenza). Una volta approvato, il verbale viene congelato e reso immutabile.
|
||||
</div>
|
||||
<div class="mr-note">
|
||||
Ancoraggio: <strong>ISO/IEC 27001 §9.3</strong> (riesame di direzione) e buona prassi di governance NIS2 (<strong>GV.PO-02</strong>,
|
||||
art. 23 D.Lgs. 138/2024). Strumento di supporto e pre-audit: non sostituisce l'auditor.
|
||||
</div>
|
||||
|
||||
<div class="mr-toolbar"><button class="btn btn-primary btn-sm" onclick="mrOpen()">+ Nuovo riesame</button></div>
|
||||
<div id="mr-list" aria-live="polite"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modale dettaglio / verbale -->
|
||||
<div class="sm-overlay" id="mr-modal" role="dialog" aria-modal="true" aria-labelledby="mr-modal-title">
|
||||
<div class="sm-dialog">
|
||||
<div class="sm-head"><h3 id="mr-modal-title">Nuovo riesame</h3><button class="sm-close" type="button" aria-label="Chiudi" onclick="mrClose()">×</button></div>
|
||||
<div class="sm-body">
|
||||
<input type="hidden" id="mr-id">
|
||||
<div class="sm-row">
|
||||
<div class="sm-field"><label for="mr-date">Data riesame</label><input type="date" id="mr-date"></div>
|
||||
<div class="sm-field"><label for="mr-period">Periodo di riferimento <span class="hint">(es. 2026 H1)</span></label><input type="text" id="mr-period" maxlength="100"></div>
|
||||
</div>
|
||||
<div class="sm-field"><label for="mr-chair">Presidente del riesame</label><select id="mr-chair"><option value="">— Nessuno —</option></select></div>
|
||||
<div class="sm-field"><label for="mr-attendees">Partecipanti <span class="hint">(uno per riga: Nome — ruolo)</span></label><textarea id="mr-attendees" placeholder="Mario Rossi — CISO Anna Bianchi — DPO"></textarea></div>
|
||||
|
||||
<div class="sm-field">
|
||||
<label>Elementi in ingresso (§9.3.2)</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
||||
<button class="btn btn-outline btn-sm" type="button" id="mr-gather-btn" onclick="mrGather()">Aggrega dati</button>
|
||||
<span class="hint" id="mr-gather-info"></span>
|
||||
</div>
|
||||
<div id="mr-inputs"><div class="mr-empty">Premi "Aggrega dati" per raccogliere automaticamente gli input dai moduli.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="sm-field"><label for="mr-conclusions">Conclusioni</label><textarea id="mr-conclusions" style="min-height:90px;"></textarea></div>
|
||||
|
||||
<div class="sm-field" id="mr-dec-wrap">
|
||||
<label>Decisioni e azioni (elementi in uscita §9.3.3)</label>
|
||||
<div id="mr-decisions"></div>
|
||||
<button class="btn btn-outline btn-sm" type="button" id="mr-dec-add" onclick="decAdd()">+ Aggiungi decisione</button>
|
||||
</div>
|
||||
|
||||
<div class="sm-err" id="mr-err" role="alert"></div>
|
||||
</div>
|
||||
<div class="sm-foot">
|
||||
<a class="btn btn-outline hidden" id="mr-report-link" href="#" target="_blank" rel="noopener">Verbale (stampabile)</a>
|
||||
<button class="btn btn-outline hidden" id="mr-approve" type="button" onclick="mrApprove()">Approva e congela</button>
|
||||
<button class="btn btn-outline" type="button" onclick="mrClose()">Chiudi</button>
|
||||
<button class="btn btn-primary" type="button" id="mr-save" onclick="mrSave()">Salva</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
/*
|
||||
* NIS2 Agile - Riesame di Direzione (ISO 27001 §9.3) — Modulo B
|
||||
* Lista riesami + form verbale: input aggregati (gather), decisioni, draft→approvato, report.
|
||||
*/
|
||||
let MR = { reviews: [], members: [], roles: [], detail: null, gather: null, decisions: [] };
|
||||
const ST_LABELS = { draft: 'Bozza', approved: 'Approvato' };
|
||||
const DEC_LABELS = { open: 'Aperta', in_progress: 'In corso', done: 'Conclusa' };
|
||||
const SEC_TITLES = {
|
||||
corrective_actions: 'Azioni dal riesame precedente / CAPA aperte',
|
||||
stakeholders: 'Contesto e parti interessate',
|
||||
internal_audits: 'Risultati audit interni',
|
||||
nonconformities: 'Non conformità e azioni correttive',
|
||||
compliance_score: 'Monitoraggio (avanzamento SoA / KPI)',
|
||||
objectives: 'Obiettivi del SGSI',
|
||||
risks: 'Rischi e stato del trattamento',
|
||||
normative: 'Aggiornamenti normativi non riscontrati',
|
||||
training: 'Formazione e consapevolezza',
|
||||
upcoming_deadlines: 'Scadenze imminenti (90 gg)'
|
||||
};
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
||||
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function () {
|
||||
if (typeof checkAuth === 'function' && !checkAuth()) return;
|
||||
if (window.I18n && I18n.init) I18n.init('it');
|
||||
if (typeof loadSidebar === 'function') loadSidebar();
|
||||
if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') mrClose(); });
|
||||
const ov = el('mr-modal'); if (ov) ov.addEventListener('click', e => { if (e.target === ov) mrClose(); });
|
||||
await mrLoadAll();
|
||||
});
|
||||
|
||||
async function mrLoadAll() {
|
||||
el('mr-list').innerHTML = '<div class="mr-empty">Caricamento…</div>';
|
||||
try {
|
||||
const [revs, members, roles] = await Promise.all([
|
||||
api.mgmtReviewList(),
|
||||
safe(() => api.orgMembers ? api.orgMembers() : null),
|
||||
safe(() => api.orgRolesList())
|
||||
]);
|
||||
MR.reviews = (revs && revs.reviews) || [];
|
||||
MR.members = membersFrom(members);
|
||||
MR.roles = (roles && roles.roles) || (roles && roles.org_roles) || [];
|
||||
renderList();
|
||||
} catch (e) {
|
||||
el('mr-list').innerHTML = '<div class="mr-empty">Errore: ' + esc(e.message || e) + '</div>';
|
||||
}
|
||||
}
|
||||
async function safe(fn) { try { return await fn(); } catch (e) { return null; } }
|
||||
function membersFrom(m) {
|
||||
if (!m) return [];
|
||||
const arr = m.members || m.users || m.items || (Array.isArray(m) ? m : []);
|
||||
return (arr || []).map(u => ({ id: u.user_id || u.id, name: u.full_name || u.name || u.email || ('Utente ' + (u.user_id || u.id)) }));
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!MR.reviews.length) { el('mr-list').innerHTML = '<div class="mr-empty">Nessun riesame. Creane uno con "+ Nuovo riesame".</div>'; return; }
|
||||
const rows = MR.reviews.map(r =>
|
||||
'<tr class="click" onclick="mrOpen(' + r.id + ')">'
|
||||
+ '<td><strong>' + esc(r.code || '—') + '</strong></td>'
|
||||
+ '<td>' + esc(r.review_date || '—') + '</td>'
|
||||
+ '<td>' + esc(r.period_label || '—') + '</td>'
|
||||
+ '<td>' + esc(r.chair_name || '—') + '</td>'
|
||||
+ '<td>' + r.n_done + '/' + r.n_decisions + '</td>'
|
||||
+ '<td><span class="mr-badge s-' + r.status + '">' + (ST_LABELS[r.status] || r.status) + '</span></td>'
|
||||
+ '</tr>'
|
||||
).join('');
|
||||
el('mr-list').innerHTML = '<table class="mr-table"><thead><tr>'
|
||||
+ '<th>Codice</th><th>Data</th><th>Periodo</th><th>Presidente</th><th>Decisioni</th><th>Stato</th>'
|
||||
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
function fillChair(selectedId) {
|
||||
el('mr-chair').innerHTML = '<option value="">— Nessuno —</option>'
|
||||
+ MR.members.map(u => '<option value="' + u.id + '"' + (String(u.id) === String(selectedId) ? ' selected' : '') + '>' + esc(u.name) + '</option>').join('');
|
||||
}
|
||||
function roleOptions(selectedId) {
|
||||
return '<option value="">— Nessun ruolo —</option>'
|
||||
+ MR.roles.map(r => '<option value="' + r.id + '"' + (String(r.id) === String(selectedId) ? ' selected' : '') + '>' + esc(r.role_name || r.name) + '</option>').join('');
|
||||
}
|
||||
|
||||
async function mrOpen(id) {
|
||||
el('mr-id').value = id || '';
|
||||
el('mr-err').textContent = '';
|
||||
el('mr-gather-info').textContent = '';
|
||||
MR.detail = null; MR.gather = null; MR.decisions = [];
|
||||
let r = null;
|
||||
if (id) { try { r = await api.mgmtReviewGet(id); MR.detail = r; } catch (e) { el('mr-err').textContent = e.message; } }
|
||||
|
||||
el('mr-modal-title').textContent = id ? ('Riesame ' + (r ? (r.code || '') : '')) : 'Nuovo riesame';
|
||||
el('mr-date').value = r ? (r.review_date || '') : '';
|
||||
el('mr-period').value = r ? (r.period_label || '') : '';
|
||||
fillChair(r ? r.chair_user_id : '');
|
||||
el('mr-attendees').value = r && r.attendees ? r.attendees.map(a => a.name + (a.role ? ' — ' + a.role : '')).join('\n') : '';
|
||||
el('mr-conclusions').value = r ? (r.conclusions || '') : '';
|
||||
|
||||
MR.gather = r && r.snapshot ? r.snapshot : null;
|
||||
renderInputs();
|
||||
|
||||
MR.decisions = r && r.decisions ? r.decisions.slice() : [];
|
||||
renderDecisions();
|
||||
|
||||
const approved = r && r.status === 'approved';
|
||||
const fields = ['mr-date','mr-period','mr-chair','mr-attendees','mr-conclusions'];
|
||||
fields.forEach(f => { el(f).disabled = !!approved; });
|
||||
el('mr-save').classList.toggle('hidden', !!approved);
|
||||
el('mr-gather-btn').classList.toggle('hidden', !!approved);
|
||||
el('mr-dec-add').classList.toggle('hidden', !!approved);
|
||||
el('mr-approve').classList.toggle('hidden', !id || !!approved);
|
||||
const rl = el('mr-report-link');
|
||||
if (id) {
|
||||
const orgQ = api && api.orgId ? ('&org_id=' + encodeURIComponent(api.orgId)) : '';
|
||||
rl.href = (api.baseUrl || '/api') + '/management-reviews/' + id + '/report?token=' + encodeURIComponent(token()) + orgQ;
|
||||
rl.classList.remove('hidden');
|
||||
} else { rl.classList.add('hidden'); }
|
||||
|
||||
el('mr-modal').classList.add('open');
|
||||
setTimeout(function () { var fe = el('mr-modal').querySelector('input,select,textarea'); if (fe) fe.focus(); }, 50);
|
||||
}
|
||||
function mrClose() { el('mr-modal').classList.remove('open'); }
|
||||
function token() { try { return (api && api.token) || localStorage.getItem('nis2_access_token') || ''; } catch (e) { return ''; } }
|
||||
|
||||
function renderInputs() {
|
||||
const sections = (MR.gather && MR.gather.sections) || null;
|
||||
if (!sections) { el('mr-inputs').innerHTML = '<div class="mr-empty">Nessun dato aggregato. Premi "Aggrega dati".</div>'; return; }
|
||||
let html = '';
|
||||
Object.keys(SEC_TITLES).forEach(key => {
|
||||
const sec = sections[key];
|
||||
const na = !sec || !sec.available;
|
||||
html += '<div class="mr-section' + (na ? ' na' : '') + '"><h4>' + esc(SEC_TITLES[key]) + (na ? '<span class="mr-pill">n/d</span>' : '') + '</h4>';
|
||||
html += '<div class="body">';
|
||||
if (na) { html += 'Dato non disponibile.'; }
|
||||
else {
|
||||
html += esc(summarize(key, sec));
|
||||
const items = sec.items || [];
|
||||
if (items.length) {
|
||||
html += '<ul>' + items.slice(0, 8).map(it => {
|
||||
const label = it.title || it.code || '';
|
||||
const extra = ['code','status','severity','due_date','next_review_date','impact','reference']
|
||||
.filter(f => it[f] && it[f] !== label).map(f => esc(it[f])).join(' · ');
|
||||
return '<li>' + esc(label) + (extra ? ' <span class="hint">(' + extra + ')</span>' : '') + '</li>';
|
||||
}).join('') + (items.length > 8 ? '<li class="hint">… e altri ' + (items.length - 8) + '</li>' : '') + '</ul>';
|
||||
}
|
||||
}
|
||||
html += '</div></div>';
|
||||
});
|
||||
el('mr-inputs').innerHTML = html;
|
||||
}
|
||||
function summarize(key, sec) {
|
||||
switch (key) {
|
||||
case 'nonconformities': return (sec.open_count || 0) + ' non conformità aperte.';
|
||||
case 'corrective_actions': return (sec.open_count || 0) + ' azioni correttive in corso.';
|
||||
case 'internal_audits': return (sec.total || 0) + ' audit interni (' + (sec.completed || 0) + ' completati).';
|
||||
case 'risks': { const t = sec.treatments || {}; return (sec.total || 0) + ' rischi; trattamenti ' + (t.completed || 0) + ' completati, ' + (t.overdue || 0) + ' in ritardo.'; }
|
||||
case 'compliance_score': return 'Avanzamento medio SoA: ' + (sec.soa_avg_implementation != null ? sec.soa_avg_implementation + '%' : 'n/d') + '.';
|
||||
case 'objectives': return (sec.count || 0) + ' obiettivi SGSI.';
|
||||
case 'training': return (sec.completed || 0) + '/' + (sec.total || 0) + ' completate, ' + (sec.overdue || 0) + ' in ritardo.';
|
||||
case 'stakeholders': return (sec.total || 0) + ' attività stakeholder (' + (sec.completed || 0) + ' completate).';
|
||||
case 'normative': return (sec.pending_count || 0) + ' aggiornamenti normativi da riscontrare.';
|
||||
case 'upcoming_deadlines': return (sec.count || 0) + ' scadenze nei prossimi 90 giorni.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function mrGather() {
|
||||
const btn = el('mr-gather-btn'); btn.disabled = true;
|
||||
el('mr-gather-info').textContent = 'Aggregazione in corso…';
|
||||
try {
|
||||
MR.gather = await api.mgmtReviewGather();
|
||||
renderInputs();
|
||||
el('mr-gather-info').textContent = 'Dati aggregati: ' + ((MR.gather && MR.gather.generated_at) || '');
|
||||
} catch (e) {
|
||||
el('mr-gather-info').textContent = 'Errore: ' + (e.message || e);
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
// ── Decisioni ──
|
||||
function renderDecisions() {
|
||||
const wrap = el('mr-decisions');
|
||||
const approved = MR.detail && MR.detail.status === 'approved';
|
||||
if (!MR.decisions.length) { wrap.innerHTML = '<div class="mr-empty">Nessuna decisione. Aggiungine una.</div>'; return; }
|
||||
wrap.innerHTML = MR.decisions.map((d, i) => {
|
||||
if (approved) {
|
||||
return '<div class="dec-row"><div class="grow">' + esc(d.decision) + '</div>'
|
||||
+ '<span class="dec-stat dec-' + d.status + '">' + (DEC_LABELS[d.status] || d.status) + '</span>'
|
||||
+ (d.owner_role_name ? '<span class="hint">' + esc(d.owner_role_name) + '</span>' : '')
|
||||
+ (d.due_date ? '<span class="hint">' + esc(d.due_date) + '</span>' : '')
|
||||
+ (d.capa_code ? '<span class="mr-pill">' + esc(d.capa_code) + '</span>' : '') + '</div>';
|
||||
}
|
||||
return '<div class="dec-row" data-i="' + i + '">'
|
||||
+ '<input type="text" class="grow" value="' + escAttr(d.decision) + '" oninput="decEdit(' + i + ',\'decision\',this.value)" placeholder="Decisione…">'
|
||||
+ '<select onchange="decEdit(' + i + ',\'owner_role_id\',this.value)">' + roleOptions(d.owner_role_id) + '</select>'
|
||||
+ '<input type="date" value="' + escAttr(d.due_date || '') + '" onchange="decEdit(' + i + ',\'due_date\',this.value)">'
|
||||
+ '<select onchange="decEdit(' + i + ',\'status\',this.value)">'
|
||||
+ ['open','in_progress','done'].map(s => '<option value="' + s + '"' + (d.status === s ? ' selected' : '') + '>' + DEC_LABELS[s] + '</option>').join('')
|
||||
+ '</select>'
|
||||
+ '<button class="dec-del" type="button" onclick="decDel(' + i + ')" aria-label="Rimuovi">×</button>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
}
|
||||
function decAdd() {
|
||||
if (!el('mr-id').value) { el('mr-err').textContent = 'Salva prima il riesame, poi aggiungi le decisioni.'; return; }
|
||||
MR.decisions.push({ id: null, decision: '', owner_role_id: null, due_date: '', status: 'open', capa_code: null, _new: true });
|
||||
renderDecisions();
|
||||
}
|
||||
function decEdit(i, field, val) { if (MR.decisions[i]) { MR.decisions[i][field] = val; MR.decisions[i]._dirty = true; } }
|
||||
function decDel(i) {
|
||||
const d = MR.decisions[i]; if (!d) return;
|
||||
// Niente endpoint di cancellazione lato server: si rimuovono solo le decisioni NON ancora salvate.
|
||||
if (d.id) { el('mr-err').textContent = 'Una decisione salvata non può essere cancellata: impostala su "Conclusa".'; return; }
|
||||
MR.decisions.splice(i, 1); renderDecisions();
|
||||
}
|
||||
|
||||
async function mrSave() {
|
||||
const id = el('mr-id').value;
|
||||
const payload = {
|
||||
review_date: el('mr-date').value || null,
|
||||
period_label: el('mr-period').value.trim() || null,
|
||||
chair_user_id: el('mr-chair').value ? parseInt(el('mr-chair').value, 10) : null,
|
||||
attendees: parseAttendees(el('mr-attendees').value),
|
||||
conclusions: el('mr-conclusions').value.trim() || null
|
||||
};
|
||||
if (MR.gather) payload.snapshot = MR.gather;
|
||||
const btn = el('mr-save'); btn.disabled = true;
|
||||
try {
|
||||
let reviewId;
|
||||
if (id) { await api.mgmtReviewUpdate(parseInt(id, 10), payload); reviewId = parseInt(id, 10); }
|
||||
else { const r = await api.mgmtReviewCreate(payload); reviewId = r.id; el('mr-id').value = reviewId; }
|
||||
await syncDecisions(reviewId);
|
||||
showNotification(id ? 'Riesame aggiornato.' : 'Riesame creato.', 'success');
|
||||
await mrLoadAll();
|
||||
await mrOpen(reviewId);
|
||||
} catch (e) {
|
||||
el('mr-err').textContent = e.message || String(e);
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
async function syncDecisions(reviewId) {
|
||||
for (const d of MR.decisions) {
|
||||
const body = { decision: (d.decision || '').trim(), owner_role_id: d.owner_role_id ? parseInt(d.owner_role_id, 10) : null, due_date: d.due_date || null, status: d.status || 'open' };
|
||||
if (!body.decision) continue;
|
||||
if (d.id) { if (d._dirty) await api.mgmtReviewUpdateDecision(d.id, body); }
|
||||
else { await api.mgmtReviewAddDecision(reviewId, body); }
|
||||
}
|
||||
}
|
||||
|
||||
function parseAttendees(text) {
|
||||
return (text || '').split('\n').map(l => l.trim()).filter(Boolean).map(l => {
|
||||
const parts = l.split('—');
|
||||
if (parts.length >= 2) return { name: parts[0].trim(), role: parts.slice(1).join('—').trim() };
|
||||
const d = l.split(' - ');
|
||||
if (d.length >= 2) return { name: d[0].trim(), role: d.slice(1).join(' - ').trim() };
|
||||
return { name: l };
|
||||
});
|
||||
}
|
||||
|
||||
async function mrApprove() {
|
||||
const id = el('mr-id').value; if (!id) return;
|
||||
if (!confirm('Approvare e congelare il verbale? Dopo l\'approvazione non sarà più modificabile.')) return;
|
||||
const btn = el('mr-approve'); btn.disabled = true;
|
||||
try {
|
||||
// salva eventuali modifiche pendenti prima del congelamento
|
||||
await mrSilentSave(parseInt(id, 10));
|
||||
await api.mgmtReviewApprove(parseInt(id, 10));
|
||||
showNotification('Riesame approvato e congelato.', 'success');
|
||||
await mrLoadAll();
|
||||
await mrOpen(parseInt(id, 10));
|
||||
} catch (e) {
|
||||
el('mr-err').textContent = e.message || String(e);
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
async function mrSilentSave(reviewId) {
|
||||
const payload = {
|
||||
review_date: el('mr-date').value || null,
|
||||
period_label: el('mr-period').value.trim() || null,
|
||||
chair_user_id: el('mr-chair').value ? parseInt(el('mr-chair').value, 10) : null,
|
||||
attendees: parseAttendees(el('mr-attendees').value),
|
||||
conclusions: el('mr-conclusions').value.trim() || null
|
||||
};
|
||||
if (MR.gather) payload.snapshot = MR.gather;
|
||||
await api.mgmtReviewUpdate(reviewId, payload);
|
||||
await syncDecisions(reviewId);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>Misure e Requisiti - NIS2 Agile</title>
|
||||
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. -->
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260629">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260630">
|
||||
<style>
|
||||
.mr-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:14px; font-size:.92rem; line-height:1.6; }
|
||||
.mr-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
||||
@@ -162,12 +162,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
/*
|
||||
|
||||
@@ -424,6 +424,6 @@ curl https://nis2.agile.software/api/services/status</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="js/common.js?v=20260629"></script>
|
||||
<script src="js/common.js?v=20260630"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -112,8 +112,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -123,9 +123,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
|
||||
@@ -494,8 +494,8 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della onboarding.html: ZERO modifiche backend -->
|
||||
<script src="js/api.js?v=20260629"></script>
|
||||
<script src="js/common.js?v=20260629"></script>
|
||||
<script src="js/api.js?v=20260630"></script>
|
||||
<script src="js/common.js?v=20260630"></script>
|
||||
<script>
|
||||
// ── Auth check ──────────────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -138,12 +138,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script src="/js/organigramma.js?v=20260617"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -333,8 +333,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -344,9 +344,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
+5
-5
@@ -131,12 +131,12 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script src="/js/raci.js?v=20260619"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -268,8 +268,8 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della register.html: ZERO modifiche backend -->
|
||||
<script src="js/api.js?v=20260629"></script>
|
||||
<script src="js/common.js?v=20260629"></script>
|
||||
<script src="js/api.js?v=20260630"></script>
|
||||
<script src="js/common.js?v=20260630"></script>
|
||||
<script>
|
||||
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
|
||||
|
||||
|
||||
+5
-5
@@ -443,8 +443,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -454,9 +454,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -141,12 +141,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
/*
|
||||
|
||||
+5
-5
@@ -494,8 +494,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -505,9 +505,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
// ── Auth & Init ──────────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -672,8 +672,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -683,9 +683,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -165,8 +165,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js?v=20260629"></script>
|
||||
<script src="js/common.js?v=20260629"></script>
|
||||
<script src="js/api.js?v=20260630"></script>
|
||||
<script src="js/common.js?v=20260630"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Attività stakeholder - NIS2 Agile</title>
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260629">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260630">
|
||||
<style>
|
||||
.sa-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
|
||||
.sa-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
||||
@@ -194,12 +194,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
/*
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>Stakeholder - NIS2 Agile</title>
|
||||
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. -->
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260629">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260630">
|
||||
<style>
|
||||
.stk-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
|
||||
.stk-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
||||
@@ -191,12 +191,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
/*
|
||||
|
||||
@@ -477,8 +477,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -488,9 +488,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
* Il nome cache include la release: va BUMPATO ad ogni rilascio (vedi version.json),
|
||||
* cosi' activate elimina automaticamente la shell vecchia.
|
||||
*/
|
||||
const CACHE = 'nis2-shell-v1.21.3';
|
||||
const CACHE = 'nis2-shell-v1.22.0';
|
||||
|
||||
const PRECACHE = [
|
||||
'/offline.html',
|
||||
|
||||
@@ -292,8 +292,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -303,9 +303,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version": "1.21.3", "build": "2026-06-16-v1.21.3", "date": "2026-06-16", "changelog": "Epic C / C5 chiusura open-items: aggancio invio email questionari/firma-lettura in send() (GATED dal kill-switch EMAIL_SENDING_ENABLED=false -> nessun invio finora, pronto per go-live; magic-link assoluto via APP_URL). nis2_sources.php: nota di provenienza 92 vs 87 (prevale dato referente Simon). [host] EMAIL_MS_URL spostato sul path interno; pre-equip PHP-CA DB confermato (TLS1.3 attivo). Additivo."}
|
||||
{"version": "1.22.0", "build": "2026-06-17-v1.22.0", "date": "2026-06-17", "changelog": "Rilascio ISO-readiness (3 moduli): Audit interni (§9.2, mig.055: internal_audits + items, checklist auto-popolata clausole 4-10 + Annex A dal SoA, esiti per riga, apertura NC->NCR/CAPA, report stampabile); Riesame di Direzione (§9.3, mig.056: management_reviews + decisions, INPUT aggregati automaticamente dai moduli (gather), verbale stampabile, approvazione con snapshot congelato); Calendario unico scadenze (aggregatore sola lettura di tutte le scadenze: incidenti/policy/rischi/NC-CAPA/formazione/stakeholder/audit/riesame/review_schedule, griglia mensile+lista+filtri). review_schedule ENUM esteso (internal_audit). Additivo."}
|
||||
|
||||
@@ -207,8 +207,8 @@
|
||||
|
||||
<!-- Report Detail Modal handled by common.js showModal -->
|
||||
|
||||
<script src="/js/api.js?v=20260629"></script>
|
||||
<script src="/js/common.js?v=20260629"></script>
|
||||
<script src="/js/api.js?v=20260630"></script>
|
||||
<script src="/js/common.js?v=20260630"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -218,9 +218,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260629"></script>
|
||||
<script src="/js/i18n.js?v=20260629"></script>
|
||||
<script src="/js/help.js?v=20260629"></script>
|
||||
<script src="/js/common-bi.js?v=20260630"></script>
|
||||
<script src="/js/i18n.js?v=20260630"></script>
|
||||
<script src="/js/help.js?v=20260630"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
|
||||
@@ -319,9 +319,9 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js?v=20260629"></script>
|
||||
<script src="js/common.js?v=20260629"></script>
|
||||
<script src="js/i18n.js?v=20260629"></script>
|
||||
<script src="js/api.js?v=20260630"></script>
|
||||
<script src="js/common.js?v=20260630"></script>
|
||||
<script src="js/i18n.js?v=20260630"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
|
||||
Reference in New Issue
Block a user