[FEAT] A4 Fasi 4.4 + 4.5 (A4 COMPLETA 5/5): scadenziario centralizzato review_schedule (mig.044; ReviewScheduleController list/create/update/delete/complete/sync) + stakeholder estesi clienti/partner GV.SC-02 (mig.045 suppliers.stakeholder_type, SupplyChainController::stakeholderMap). Pagine review-schedule.html + stakeholders.html (Bootstrap Italia/AGID). Workflow build+review adversariale (3 major risolti: 045 da DELIMITER -> ALTER bare runner-safe). help GV.SC-04/05 allineati al titolo canonico ACN. Cache-buster ?v=20260620.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-15 23:10:28 +02:00
co-authored by Claude Opus 4.8
parent c7d36c910f
commit c5b00ccd96
48 changed files with 1597 additions and 122 deletions
@@ -0,0 +1,491 @@
<?php
/**
* NIS2 Agile - Scadenziario centralizzato delle revisioni (A4 Fase 4.4)
* ----------------------------------------------------------------------------
* Registro unico delle scadenze di revisione periodica: ruoli, competenze,
* inventario, procedure, rischi, fornitori, misure e voci custom. Lo STATO
* (ok/due/overdue) e' calcolato LIVE in base a next_review_date e NON ci si
* affida alla colonna persistita (computeStatus()).
*
* Multi-tenancy ancorata a getCurrentOrgId(): OGNI query filtra organization_id.
* Le letture passano requireOrgAccess(); le scritture richiedono
* org_admin/compliance_manager (super_admin bypassa; il demo guard gestisce il
* read-only/sandbox automaticamente in BaseController). Anti-IDOR: owner_role_id
* e' verificato come appartenente all'org (fetchRoleOrFail).
*
* sync(): upsert idempotente di voci dalle scadenze gia' presenti nel prodotto,
* tutte org-scoped, con anti-dup su (entity_type, entity_id):
* - policies.next_review_date -> entity_type='procedure'
* - compliance_controls.next_review_date -> entity_type='custom'
* - risk_treatments.due_date -> entity_type='risk' (JOIN risks: la
* tabella risk_treatments NON ha organization_id, si filtra via risks)
*
* Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md):
* - GV.PO-02: policy/procedure riesaminate periodicamente
* - GV.SC-07: sicurezza fornitori monitorata e rivista
* - PR.AT: formazione/sensibilizzazione ricorrenti
* - DE.CM: monitoraggio continuo
* - art. 24 D.Lgs. 138/2024: misure di gestione del rischio mantenute aggiornate
* Disclaimer: la periodicita' puntuale e' buona prassi dove non fissata da norma;
* strumento di supporto, non un parere legale.
*
* NOTE strutturali (verificate sul codice reale):
* - DB API: Database::query/fetchAll/fetchOne/insert/update/delete/count
* (NON esiste Database::execute).
* - Le AZIONI sono capa_actions (figlie di non_conformities): 4.4 non le usa.
*/
require_once __DIR__ . '/BaseController.php';
class ReviewScheduleController extends BaseController
{
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
private const ENTITY_TYPES = [
'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom',
];
// ═══════════════════════════════════════════════════════════════════════
// LETTURA
// ═══════════════════════════════════════════════════════════════════════
/**
* GET /api/review-schedule/list
* Elenco scadenze dell'org, ordinate per prossima revisione. Lo stato e'
* ricalcolato LIVE (override del valore in colonna). Include il nome del
* ruolo owner e i contatori di sintesi.
*/
public function list(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$rows = Database::fetchAll(
'SELECT rs.id, rs.entity_type, rs.entity_id, rs.title, rs.owner_role_id,
rs.frequency_months, rs.last_reviewed_at, rs.next_review_date,
rs.notes, rs.created_at, rs.updated_at,
r.role_name AS owner_role_name
FROM review_schedule rs
LEFT JOIN org_roles r ON r.id = rs.owner_role_id
WHERE rs.organization_id = ?
ORDER BY rs.next_review_date ASC, rs.id ASC',
[$orgId]
);
$items = [];
$counts = ['ok' => 0, 'due' => 0, 'overdue' => 0];
foreach ($rows as $r) {
$status = $this->computeStatus($r['next_review_date']);
$counts[$status]++;
$items[] = [
'id' => (int) $r['id'],
'entity_type' => $r['entity_type'],
'entity_id' => $r['entity_id'] !== null ? (int) $r['entity_id'] : null,
'title' => $r['title'],
'owner_role_id' => $r['owner_role_id'] !== null ? (int) $r['owner_role_id'] : null,
'owner_role_name' => $r['owner_role_name'],
'frequency_months' => $r['frequency_months'] !== null ? (int) $r['frequency_months'] : null,
'last_reviewed_at' => $r['last_reviewed_at'],
'next_review_date' => $r['next_review_date'],
'status' => $status,
'notes' => $r['notes'],
'created_at' => $r['created_at'],
'updated_at' => $r['updated_at'],
];
}
$this->jsonSuccess([
'items' => $items,
'total' => count($items),
'ok' => $counts['ok'],
'due' => $counts['due'],
'overdue' => $counts['overdue'],
]);
}
// ═══════════════════════════════════════════════════════════════════════
// SCRITTURA
// ═══════════════════════════════════════════════════════════════════════
/**
* POST /api/review-schedule/create
* Body: {title*, next_review_date*, entity_type?, entity_id?, owner_role_id?,
* frequency_months?, notes?}
*/
public function create(): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$this->validateRequired(['title', 'next_review_date']);
$orgId = $this->getCurrentOrgId();
$type = $this->validateEntityType($this->getParam('entity_type', 'custom'));
$nextDate = $this->validateDate($this->getParam('next_review_date'));
$ownerId = $this->resolveOwnerRoleId($this->getParam('owner_role_id'));
$freq = $this->nullablePositiveInt($this->getParam('frequency_months'));
$entityId = $this->nullableInt($this->getParam('entity_id'));
$id = Database::insert('review_schedule', [
'organization_id' => $orgId,
'entity_type' => $type,
'entity_id' => $entityId,
'title' => trim((string) $this->getParam('title')),
'owner_role_id' => $ownerId,
'frequency_months' => $freq,
'last_reviewed_at' => null,
'next_review_date' => $nextDate,
'status' => $this->computeStatus($nextDate),
'notes' => $this->nullableText($this->getParam('notes')),
'created_by' => $this->getCurrentUserId(),
]);
$this->logAudit('review_schedule_created', 'review_schedule', $id, [
'entity_type' => $type, 'next_review_date' => $nextDate,
]);
$this->jsonSuccess(['id' => $id], 'Voce di scadenziario creata', 201);
}
/**
* PUT /api/review-schedule/{id}
* Aggiorna solo i campi presenti nel body. Ricalcola lo stato se cambia la
* prossima revisione.
*/
public function update(int $id): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$orgId = $this->getCurrentOrgId();
$row = $this->fetchEntryOrFail($id, $orgId);
$updates = [];
if ($this->hasParam('title')) {
$title = trim((string) $this->getParam('title'));
if ($title === '') {
$this->jsonError('Il titolo non puo essere vuoto', 422, 'INVALID_TITLE');
}
$updates['title'] = $title;
}
if ($this->hasParam('entity_type')) {
$updates['entity_type'] = $this->validateEntityType($this->getParam('entity_type'));
}
if ($this->hasParam('entity_id')) {
$updates['entity_id'] = $this->nullableInt($this->getParam('entity_id'));
}
if ($this->hasParam('owner_role_id')) {
$updates['owner_role_id'] = $this->resolveOwnerRoleId($this->getParam('owner_role_id'));
}
if ($this->hasParam('frequency_months')) {
$updates['frequency_months'] = $this->nullablePositiveInt($this->getParam('frequency_months'));
}
if ($this->hasParam('notes')) {
$updates['notes'] = $this->nullableText($this->getParam('notes'));
}
if ($this->hasParam('next_review_date')) {
$nextDate = $this->validateDate($this->getParam('next_review_date'));
$updates['next_review_date'] = $nextDate;
$updates['status'] = $this->computeStatus($nextDate);
}
if (empty($updates)) {
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_FIELDS');
}
Database::update('review_schedule', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]);
$this->logAudit('review_schedule_updated', 'review_schedule', $id, array_keys($updates));
$this->jsonSuccess(['id' => $id], 'Voce di scadenziario aggiornata');
}
/**
* DELETE /api/review-schedule/{id}
*/
public function delete(int $id): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$orgId = $this->getCurrentOrgId();
$deleted = Database::delete('review_schedule', 'id = ? AND organization_id = ?', [$id, $orgId]);
if ($deleted === 0) {
$this->jsonError('Voce di scadenziario non trovata', 404, 'REVIEW_NOT_FOUND');
}
$this->logAudit('review_schedule_deleted', 'review_schedule', $id, null);
$this->jsonSuccess(null, 'Voce di scadenziario eliminata');
}
/**
* POST /api/review-schedule/{id}/complete
* Segna la voce come revisionata oggi. Se ha una frequenza, avanza la
* prossima revisione di frequency_months a partire da oggi.
*/
public function complete(int $id): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$orgId = $this->getCurrentOrgId();
$row = $this->fetchEntryOrFail($id, $orgId);
$today = date('Y-m-d');
$updates = ['last_reviewed_at' => $today];
$freq = $row['frequency_months'] !== null ? (int) $row['frequency_months'] : null;
if ($freq !== null && $freq > 0) {
$nextDate = date('Y-m-d', strtotime("+{$freq} months", strtotime($today)));
$updates['next_review_date'] = $nextDate;
$updates['status'] = $this->computeStatus($nextDate);
} else {
// Nessuna cadenza: la prossima revisione resta invariata, ricalcolo lo stato.
$updates['status'] = $this->computeStatus($row['next_review_date']);
$nextDate = $row['next_review_date'];
}
Database::update('review_schedule', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]);
$this->logAudit('review_schedule_completed', 'review_schedule', $id, [
'last_reviewed_at' => $today, 'next_review_date' => $nextDate,
]);
$this->jsonSuccess([
'id' => $id,
'last_reviewed_at' => $today,
'next_review_date' => $nextDate,
'status' => $updates['status'],
], 'Revisione registrata');
}
/**
* POST /api/review-schedule/sync
* Upsert idempotente dalle scadenze gia' presenti (policy / controlli /
* trattamenti rischio), tutte org-scoped. Anti-dup su (entity_type, entity_id):
* se la voce esiste e la data sorgente e' cambiata la aggiorna, altrimenti
* crea. Le voci create a mano (entity_type='custom', entity_id NULL) non
* vengono toccate.
*/
public function sync(): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$orgId = $this->getCurrentOrgId();
$userId = $this->getCurrentUserId();
$created = 0;
$updated = 0;
$skipped = 0;
// 1. Procedure (policies) con next_review_date impostata.
$policies = Database::fetchAll(
'SELECT id, title, next_review_date
FROM policies
WHERE organization_id = ? AND next_review_date IS NOT NULL AND deleted_at IS NULL',
[$orgId]
);
foreach ($policies as $p) {
$this->upsertSyncEntry(
$orgId, $userId, 'procedure', (int) $p['id'],
(string) $p['title'], (string) $p['next_review_date'],
'Sincronizzato da procedura/policy',
$created, $updated, $skipped
);
}
// 2. Controlli di compliance con next_review_date impostata (entity_type='custom').
$controls = Database::fetchAll(
'SELECT id, control_code, title, next_review_date
FROM compliance_controls
WHERE organization_id = ? AND next_review_date IS NOT NULL',
[$orgId]
);
foreach ($controls as $c) {
$label = trim(((string) ($c['control_code'] ?? '')) . ' — ' . ((string) ($c['title'] ?? '')));
$label = trim($label, ' —');
if ($label === '') {
$label = 'Controllo #' . (int) $c['id'];
}
$this->upsertSyncEntry(
$orgId, $userId, 'custom', (int) $c['id'],
$label, (string) $c['next_review_date'],
'Sincronizzato da controllo di compliance',
$created, $updated, $skipped
);
}
// 3. Trattamenti rischio (risk_treatments.due_date) — org via JOIN su risks
// (risk_treatments NON ha organization_id).
$treatments = Database::fetchAll(
'SELECT rt.id, rt.due_date, r.title
FROM risk_treatments rt
JOIN risks r ON r.id = rt.risk_id
WHERE r.organization_id = ? AND rt.due_date IS NOT NULL AND r.deleted_at IS NULL',
[$orgId]
);
foreach ($treatments as $t) {
$title = (string) ($t['title'] ?? '');
if ($title === '') {
$title = 'Rischio (trattamento #' . (int) $t['id'] . ')';
}
$this->upsertSyncEntry(
$orgId, $userId, 'risk', (int) $t['id'],
$title, (string) $t['due_date'],
'Sincronizzato da trattamento del rischio',
$created, $updated, $skipped
);
}
$this->logAudit('review_schedule_synced', 'review_schedule', null, [
'created' => $created, 'updated' => $updated, 'skipped' => $skipped,
]);
$this->jsonSuccess([
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
], 'Sincronizzazione completata');
}
// ═══════════════════════════════════════════════════════════════════════
// PRIVATI
// ═══════════════════════════════════════════════════════════════════════
/**
* Stato calcolato LIVE: scaduto (overdue) se la data e' passata; in scadenza
* (due) se entro 30 giorni; altrimenti ok. Confronto a granularita' giorno.
*/
private function computeStatus(string $nextDate): string
{
$next = strtotime($nextDate);
$today = strtotime(date('Y-m-d'));
if ($next === false) {
return 'ok';
}
if ($next < $today) {
return 'overdue';
}
if ($next <= strtotime('+30 days', $today)) {
return 'due';
}
return 'ok';
}
/** Voce di scadenziario org-scoped oppure 404 (anti-IDOR). */
private function fetchEntryOrFail(int $id, int $orgId): array
{
$row = Database::fetchOne(
'SELECT * FROM review_schedule WHERE id = ? AND organization_id = ?',
[$id, $orgId]
);
if (!$row) {
$this->jsonError('Voce di scadenziario non trovata', 404, 'REVIEW_NOT_FOUND');
}
return $row;
}
/**
* owner_role_id opzionale: NULL/'' => null; altrimenti il ruolo deve
* appartenere all'org corrente (anti-IDOR), 404 se non trovato.
*/
private function resolveOwnerRoleId($raw): ?int
{
if ($raw === null || $raw === '' || (int) $raw === 0) {
return null;
}
$roleId = (int) $raw;
$exists = Database::count(
'org_roles', 'id = ? AND organization_id = ?', [$roleId, $this->getCurrentOrgId()]
);
if ($exists === 0) {
$this->jsonError('Ruolo owner non trovato in questa organizzazione', 404, 'ROLE_NOT_FOUND');
}
return $roleId;
}
private function validateEntityType($t): string
{
$t = strtolower((string) $t);
if ($t === '') {
return 'custom';
}
if (!in_array($t, self::ENTITY_TYPES, true)) {
$this->jsonError('Tipo entita non valido', 422, 'INVALID_ENTITY_TYPE');
}
return $t;
}
/** Valida data in formato Y-m-d (rifiuta valori non-data o impossibili). */
private function validateDate($raw): string
{
$d = trim((string) $raw);
$dt = DateTime::createFromFormat('Y-m-d', $d);
if (!$dt || $dt->format('Y-m-d') !== $d) {
$this->jsonError('Data non valida (atteso formato AAAA-MM-GG)', 422, 'INVALID_DATE');
}
return $d;
}
private function nullableInt($v): ?int
{
if ($v === null || $v === '') {
return null;
}
return (int) $v;
}
private function nullablePositiveInt($v): ?int
{
if ($v === null || $v === '') {
return null;
}
$i = (int) $v;
return $i > 0 ? $i : null;
}
private function nullableText($v): ?string
{
if ($v === null) {
return null;
}
$s = trim((string) $v);
return $s === '' ? null : $s;
}
/**
* Upsert idempotente di una voce di sync su (entity_type, entity_id):
* - non esiste -> insert (status calcolato, owner/frequenza null)
* - esiste con data diversa -> update next_review_date + status
* - esiste con stessa data -> skip
*/
private function upsertSyncEntry(
int $orgId, ?int $userId, string $type, int $entityId,
string $title, string $nextDate, string $note,
int &$created, int &$updated, int &$skipped
): void {
$existing = Database::fetchOne(
'SELECT id, next_review_date FROM review_schedule
WHERE organization_id = ? AND entity_type = ? AND entity_id = ?',
[$orgId, $type, $entityId]
);
if ($existing) {
if ((string) $existing['next_review_date'] !== $nextDate) {
Database::update('review_schedule', [
'next_review_date' => $nextDate,
'status' => $this->computeStatus($nextDate),
], 'id = ? AND organization_id = ?', [(int) $existing['id'], $orgId]);
$updated++;
} else {
$skipped++;
}
return;
}
Database::insert('review_schedule', [
'organization_id' => $orgId,
'entity_type' => $type,
'entity_id' => $entityId,
'title' => mb_substr($title, 0, 255),
'owner_role_id' => null,
'frequency_months' => null,
'last_reviewed_at' => null,
'next_review_date' => $nextDate,
'status' => $this->computeStatus($nextDate),
'notes' => $note,
'created_by' => $userId,
]);
$created++;
}
}
@@ -29,6 +29,7 @@ class SupplyChainController extends BaseController
$supplierId = Database::insert('suppliers', [ $supplierId = Database::insert('suppliers', [
'organization_id' => $this->getCurrentOrgId(), 'organization_id' => $this->getCurrentOrgId(),
'name' => trim($this->getParam('name')), 'name' => trim($this->getParam('name')),
'stakeholder_type' => $this->validateStakeholderType($this->getParam('stakeholder_type', 'supplier')),
'vat_number' => $this->getParam('vat_number'), 'vat_number' => $this->getParam('vat_number'),
'contact_email' => $this->getParam('contact_email'), 'contact_email' => $this->getParam('contact_email'),
'contact_name' => $this->getParam('contact_name'), 'contact_name' => $this->getParam('contact_name'),
@@ -76,6 +77,11 @@ class SupplyChainController extends BaseController
} }
} }
// stakeholder_type (GV.SC-02): gestito a parte per validare l'enum
if ($this->hasParam('stakeholder_type')) {
$updates['stakeholder_type'] = $this->validateStakeholderType($this->getParam('stakeholder_type'));
}
if (!empty($updates)) { if (!empty($updates)) {
Database::update('suppliers', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); Database::update('suppliers', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
$this->logAudit('supplier_updated', 'supplier', $id, $updates); $this->logAudit('supplier_updated', 'supplier', $id, $updates);
@@ -146,6 +152,58 @@ class SupplyChainController extends BaseController
]); ]);
} }
// ══════════════════════════════════════════════════════════════════════
// STAKEHOLDER ESTESI (A4 Fase 4.5) — GV.SC-02: fornitori + clienti + partner
// ══════════════════════════════════════════════════════════════════════
/**
* GET /api/supply-chain/stakeholder-map — mappa di sintesi degli stakeholder
* dell'org raggruppati per tipo (supplier/customer/partner). RIUSA la tabella
* suppliers (nessun duplicato): il dettaglio fornitori resta nel modulo Supply
* Chain. Ancoraggio GV.SC-02 (ruoli/responsabilita verso fornitori, clienti E
* partner) + GV.SC-04/05/07. Org-scoped, esclude i soft-deleted.
*/
public function stakeholderMap(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$rows = Database::fetchAll(
'SELECT id, name, stakeholder_type, service_type, criticality, status,
contact_email, contact_name, risk_score
FROM suppliers
WHERE organization_id = ? AND deleted_at IS NULL
ORDER BY stakeholder_type, criticality DESC, name',
[$orgId]
);
$groups = ['supplier' => [], 'customer' => [], 'partner' => []];
foreach ($rows as $r) {
$t = $r['stakeholder_type'] ?? 'supplier';
if (!isset($groups[$t])) {
$t = 'supplier';
}
$groups[$t][] = $r;
}
$this->jsonSuccess([
'groups' => $groups,
'counts' => [
'supplier' => count($groups['supplier']),
'customer' => count($groups['customer']),
'partner' => count($groups['partner']),
],
'total' => count($rows),
]);
}
/** Normalizza/valida il tipo stakeholder (default 'supplier' se non valido). */
private function validateStakeholderType($t): string
{
$t = strtolower((string) $t);
return in_array($t, ['supplier', 'customer', 'partner'], true) ? $t : 'supplier';
}
private function calculateSupplierRiskScore(array $responses): int private function calculateSupplierRiskScore(array $responses): int
{ {
if (empty($responses)) return 0; if (empty($responses)) return 0;
+50
View File
@@ -0,0 +1,50 @@
-- ============================================================================
-- Migration 044 — A4 Fase 4.4: Scadenziario centralizzato delle revisioni.
-- ----------------------------------------------------------------------------
-- 1 tabella ADDITIVA. Idempotente (CREATE TABLE IF NOT EXISTS). Applicare con la
-- STESSA connessione PDO dell'app (container nis2-db via TCP+TLS), via runner
-- scripts/migrate-a4.php. Runner-safe: solo CREATE TABLE, niente DELIMITER /
-- stored procedure, nessun ';' dentro commenti o stringhe.
--
-- Cosa fa: raccoglie in un unico registro le scadenze di revisione periodica
-- (ruoli, competenze, inventario, procedure, rischi, fornitori, misure, voci
-- custom). Lo stato (ok/due/overdue) e' calcolato LIVE dal controller in base a
-- next_review_date e NON ci si affida alla colonna persistita.
--
-- Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md):
-- - GV.PO-02 (NIST CSF 2.0): le policy/procedure sono riesaminate periodicamente
-- - GV.SC-07 (NIST CSF 2.0): la sicurezza dei fornitori e' monitorata/rivista
-- - PR.AT: formazione e sensibilizzazione ricorrenti
-- - DE.CM: monitoraggio continuo
-- - art. 24 D.Lgs. 138/2024: misure di gestione del rischio mantenute aggiornate
-- Disclaimer: la periodicita' puntuale e' buona prassi dove non fissata da norma;
-- e' uno strumento di supporto, non un parere legale.
--
-- Multi-tenancy: organization_id NOT NULL (FK organizations, ON DELETE CASCADE).
-- owner_role_id -> org_roles (mig.041) ON DELETE SET NULL. created_by -> users
-- ON DELETE SET NULL. UNIQUE (org, entity_type, entity_id) abilita l'upsert
-- idempotente di sync() (le voci custom con entity_id NULL non collidono in MySQL).
-- ============================================================================
CREATE TABLE IF NOT EXISTS review_schedule (
id INT AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
entity_type ENUM('role','skill','inventory','procedure','risk','supplier','measure','custom') NOT NULL,
entity_id INT NULL,
title VARCHAR(255) NOT NULL,
owner_role_id INT NULL,
frequency_months INT NULL,
last_reviewed_at DATE NULL,
next_review_date DATE NOT NULL,
status ENUM('ok','due','overdue') NOT NULL DEFAULT 'ok',
notes TEXT NULL,
created_by INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (owner_role_id) REFERENCES org_roles(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uk_rs_entity (organization_id, entity_type, entity_id),
INDEX idx_rs_org_next (organization_id, next_review_date),
INDEX idx_rs_entity (organization_id, entity_type, entity_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+42
View File
@@ -0,0 +1,42 @@
-- ============================================================================
-- Migration 045 — A4 Fase 4.5: Stakeholder estesi (GV.SC-02)
-- ----------------------------------------------------------------------------
-- ADDITIVO: aggiunge la colonna suppliers.stakeholder_type per distinguere
-- FORNITORI, CLIENTI e PARTNER. RIUSA la tabella suppliers esistente (NESSUNA
-- nuova tabella, NESSUNA duplicazione del modulo Supply Chain): la pagina
-- "Stakeholder" e' solo una vista di sintesi raggruppata per tipo.
--
-- Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md):
-- - GV.SC-02 (NIST CSF 2.0): ruoli e responsabilita per fornitori, CLIENTI e
-- PARTNER stabiliti, comunicati e coordinati internamente ed esternamente.
-- - GV.SC-04/05/07: catalogazione, requisiti contrattuali e revisione periodica
-- degli stakeholder della supply chain.
-- - Gli OBBLIGHI fanno capo al D.Lgs. 138/2024 (governance art. 23, rischio
-- art. 24): NON alla Direttiva. La tassonomia degli stakeholder e' buona prassi.
--
-- Multi-tenancy: suppliers.organization_id resta il discriminante tenant. L'indice
-- (organization_id, stakeholder_type) supporta la mappa raggruppata per tipo.
--
-- IDEMPOTENZA / RUNNER:
-- MySQL 8 Ubuntu NON supporta "ADD COLUMN IF NOT EXISTS". Questo file contiene
-- SOLO statement nudi (niente DELIMITER, niente stored procedure, nessun ';'
-- dentro i commenti) ed e' percio' RUNNER-SAFE: scripts/migrate-a4.php esegue un
-- pre-check su information_schema PRIMA di questa ALTER (stesso pattern di
-- mig.040_onboarding_classification.sql) e la salta se colonna/indice esistono
-- gia'. Eseguire SEMPRE tramite il runner:
-- docker exec nis2-app php /var/www/nis2-agile/scripts/migrate-a4.php
-- NB: NON applicare questo file con un client che fa "split su ';'" SENZA il
-- pre-check del runner, altrimenti una seconda esecuzione fallirebbe (colonna
-- duplicata). Il runner garantisce l'idempotenza.
-- ============================================================================
ALTER TABLE suppliers
ADD COLUMN stakeholder_type ENUM('supplier','customer','partner') NOT NULL DEFAULT 'supplier'
COMMENT 'GV.SC-02: tipo di stakeholder (fornitore/cliente/partner)' AFTER name;
ALTER TABLE suppliers
ADD INDEX idx_suppliers_stakeholder (organization_id, stakeholder_type);
-- ROLLBACK (manuale):
-- ALTER TABLE suppliers DROP INDEX idx_suppliers_stakeholder
-- ALTER TABLE suppliers DROP COLUMN stakeholder_type
+2 -2
View File
@@ -70,8 +70,8 @@
</script> </script>
<!-- Stessa logica della app: api.js + common.js (helper) + common-bi.js (override BI) --> <!-- Stessa logica della app: api.js + common.js (helper) + common-bi.js (override BI) -->
<script src="js/api.js?v=20260619"></script> <script src="js/api.js?v=20260620"></script>
<script src="js/common.js?v=20260619"></script> <script src="js/common.js?v=20260620"></script>
<script src="js/common-bi.js?v=20260618"></script> <script src="js/common-bi.js?v=20260618"></script>
<script> <script>
// Renderizza la sidebar BI (loadSidebar è stato ridefinito da common-bi.js) // Renderizza la sidebar BI (loadSidebar è stato ridefinito da common-bi.js)
+4 -4
View File
@@ -152,8 +152,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -164,8 +164,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
'use strict'; 'use strict';
const FUNC_LABELS = { const FUNC_LABELS = {
+3 -3
View File
@@ -317,8 +317,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -329,7 +329,7 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+3 -3
View File
@@ -147,8 +147,8 @@
</main> </main>
</div> </div>
<script src="../js/api.js?v=20260619"></script> <script src="../js/api.js?v=20260620"></script>
<script src="../js/common.js?v=20260619"></script> <script src="../js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -159,7 +159,7 @@
} }
</script> </script>
<script src="../js/common-bi.js?v=20260618"></script> <script src="../js/common-bi.js?v=20260618"></script>
<script src="../js/i18n.js?v=20260619"></script> <script src="../js/i18n.js?v=20260620"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+3 -3
View File
@@ -169,8 +169,8 @@
</main> </main>
</div> </div>
<script src="../js/api.js?v=20260619"></script> <script src="../js/api.js?v=20260620"></script>
<script src="../js/common.js?v=20260619"></script> <script src="../js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -181,7 +181,7 @@
} }
</script> </script>
<script src="../js/common-bi.js?v=20260618"></script> <script src="../js/common-bi.js?v=20260618"></script>
<script src="../js/i18n.js?v=20260619"></script> <script src="../js/i18n.js?v=20260620"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+4 -4
View File
@@ -1088,10 +1088,10 @@
</main> </main>
</div> </div>
<script src="js/api.js?v=20260619"></script> <script src="js/api.js?v=20260620"></script>
<script src="js/common.js?v=20260619"></script> <script src="js/common.js?v=20260620"></script>
<script src="js/i18n.js?v=20260619"></script> <script src="js/i18n.js?v=20260620"></script>
<script src="js/help.js?v=20260619"></script> <script src="js/help.js?v=20260620"></script>
<script> <script>
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar();
+4 -4
View File
@@ -154,8 +154,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -166,8 +166,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+4 -4
View File
@@ -366,8 +366,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -378,8 +378,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+4 -4
View File
@@ -349,14 +349,14 @@
</main> </main>
</div> </div>
<script src="js/api.js?v=20260619"></script> <script src="js/api.js?v=20260620"></script>
<script src="js/common.js?v=20260619"></script> <script src="js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle + override sidebar BI (common-bi.js dopo common.js: ridefinisce solo loadSidebar) --> <!-- 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 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>if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }</script>
<script src="js/common-bi.js?v=20260618"></script> <script src="js/common-bi.js?v=20260618"></script>
<script src="js/i18n.js?v=20260619"></script> <script src="js/i18n.js?v=20260620"></script>
<script src="js/help.js?v=20260619"></script> <script src="js/help.js?v=20260620"></script>
<script> <script>
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+4 -4
View File
@@ -183,12 +183,12 @@
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script src="/js/competenze.js?v=20260617"></script> <script src="/js/competenze.js?v=20260617"></script>
</body> </body>
</html> </html>
+3 -3
View File
@@ -382,8 +382,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -394,7 +394,7 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script> <script>
const SPRITE = '/vendor/bootstrap-italia/dist/svg/sprites.svg'; 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>`; } function ico(name, cls) { return `<svg class="ico ${cls||''}" aria-hidden="true"><use href="${SPRITE}#${name}"></use></svg>`; }
+4 -4
View File
@@ -143,8 +143,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -155,8 +155,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+1 -1
View File
@@ -73,7 +73,7 @@
</script> </script>
<!-- Stessa logica della forgot-password.html: ZERO modifiche backend --> <!-- Stessa logica della forgot-password.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260619"></script> <script src="js/api.js?v=20260620"></script>
<script> <script>
const form = document.getElementById('forgot-form'); const form = document.getElementById('forgot-form');
const err = document.getElementById('err'); const err = document.getElementById('err');
+4 -4
View File
@@ -1142,8 +1142,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (accordion) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (accordion) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. --> 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> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
@@ -1153,8 +1153,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
// La guida è accessibile anche da non autenticati (utile per onboarding) // La guida è accessibile anche da non autenticati (utile per onboarding)
// ma se sei loggato carichi sidebar + i18n normalmente. Stessa logica // ma se sei loggato carichi sidebar + i18n normalmente. Stessa logica
+4 -4
View File
@@ -351,8 +351,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -363,8 +363,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
// ── Auth & Init ────────────────────────────────────────────── // ── Auth & Init ──────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+16
View File
@@ -89,6 +89,7 @@ $controllerMap = [
'org-roles' => 'OrgRoleController', // A4 Fase 4.1 — Organigramma (ruoli/gerarchia, Art.23 governance) 'org-roles' => 'OrgRoleController', // A4 Fase 4.1 — Organigramma (ruoli/gerarchia, Art.23 governance)
'competences' => 'CompetenceController', // A4 Fase 4.2 — Competenze (skill, requisiti ruolo, gap, alert->azione) 'competences' => 'CompetenceController', // A4 Fase 4.2 — Competenze (skill, requisiti ruolo, gap, alert->azione)
'raci' => 'RaciController', // A4 Fase 4.3 — Matrice RACI + link m2m (procedure/inventario/rischi/misure) 'raci' => 'RaciController', // A4 Fase 4.3 — Matrice RACI + link m2m (procedure/inventario/rischi/misure)
'review-schedule' => 'ReviewScheduleController', // A4 Fase 4.4 — Scadenziario centralizzato (revisioni periodiche GV.PO-02/GV.SC-07/PR.AT/DE.CM)
'assessments' => 'AssessmentController', 'assessments' => 'AssessmentController',
'acn-gap' => 'AcnAssessmentController', // Gap Analysis ACN (Det. 164179/2025, misure/requisiti) 'acn-gap' => 'AcnAssessmentController', // Gap Analysis ACN (Det. 164179/2025, misure/requisiti)
'isms' => 'IsmsModelController', // Modello Organizzativo SGSI (ISO 27001/27017/27018 + SoA) 'isms' => 'IsmsModelController', // Modello Organizzativo SGSI (ISO 27001/27017/27018 + SoA)
@@ -235,6 +236,19 @@ $actionMap = [
'DELETE:link' => 'unlink', 'DELETE:link' => 'unlink',
], ],
// ── ReviewScheduleController — Scadenziario centralizzato (A4 Fase 4.4) ──
// Revisioni periodiche (GV.PO-02, GV.SC-07, PR.AT, DE.CM). Status calcolato live.
// sync = upsert idempotente da policies/compliance_controls/risk_treatments (org-scoped).
// I DELETE usano {id} numerico in URL → nessun body necessario.
'review-schedule' => [
'GET:list' => 'list',
'POST:create' => 'create',
'POST:sync' => 'sync',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/complete' => 'complete',
],
// ── AssessmentController ──────────────────────── // ── AssessmentController ────────────────────────
'assessments' => [ 'assessments' => [
'GET:list' => 'list', 'GET:list' => 'list',
@@ -375,6 +389,8 @@ $actionMap = [
// Campagne questionario (Fase 2): cruscotto + invio template a fornitore // Campagne questionario (Fase 2): cruscotto + invio template a fornitore
'GET:campaigns' => 'campaigns', 'GET:campaigns' => 'campaigns',
'POST:{id}/campaigns' => 'createCampaign', // {id} = supplier id 'POST:{id}/campaigns' => 'createCampaign', // {id} = supplier id
// Stakeholder estesi (A4 Fase 4.5): vista di sintesi fornitori+clienti+partner (GV.SC-02). Riusa suppliers.
'GET:stakeholderMap' => 'stakeholderMap', // URL: /api/supply-chain/stakeholder-map
'GET:{id}' => 'get', 'GET:{id}' => 'get',
'PUT:{id}' => 'update', 'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete', 'DELETE:{id}' => 'delete',
+2 -2
View File
@@ -910,8 +910,8 @@ curl -H <span class="str">"X-API-Key: nis2_TUA_CHIAVE"</span> \
</main> </main>
<script src="js/common.js?v=20260619"></script> <script src="js/common.js?v=20260620"></script>
<script src="js/i18n.js?v=20260619"></script> <script src="js/i18n.js?v=20260620"></script>
<script> <script>
function showTab(id) { function showTab(id) {
document.querySelectorAll('.tab-btn').forEach((b, i) => { document.querySelectorAll('.tab-btn').forEach((b, i) => {
+4 -4
View File
@@ -184,8 +184,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -196,8 +196,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script src="/js/isms.js"></script> <script src="/js/isms.js"></script>
</body> </body>
</html> </html>
+27
View File
@@ -270,6 +270,33 @@ class NIS2API {
raciLink(d) { return this._acn(this.post('/raci/link', d)); } raciLink(d) { return this._acn(this.post('/raci/link', d)); }
raciUnlink(d) { return this._acn(this.del('/raci/link?link_type=' + encodeURIComponent(d.link_type) + '&a_id=' + encodeURIComponent(d.a_id) + '&b_id=' + encodeURIComponent(d.b_id))); } raciUnlink(d) { return this._acn(this.del('/raci/link?link_type=' + encodeURIComponent(d.link_type) + '&a_id=' + encodeURIComponent(d.a_id) + '&b_id=' + encodeURIComponent(d.b_id))); }
// ═══════════════════════════════════════════════════════════════════
// Scadenziario centralizzato (A4 Fase 4.4) — review_schedule. _acn.
// Revisioni periodiche (GV.PO-02/GV.SC-07/PR.AT/DE.CM). Status calcolato live.
// DELETE per {id} numerico (no body). sync = import idempotente da scadenze esistenti.
// Alias rs* === rev* (stessa firma) per compatibilità di naming.
// ═══════════════════════════════════════════════════════════════════
revList() { return this._acn(this.get('/review-schedule/list')); }
revCreate(d) { return this._acn(this.post('/review-schedule/create', d || {})); }
revUpdate(id, d) { return this._acn(this.put(`/review-schedule/${id}`, d)); }
revDelete(id) { return this._acn(this.del(`/review-schedule/${id}`)); }
revComplete(id) { return this._acn(this.post(`/review-schedule/${id}/complete`, {})); }
revSync() { return this._acn(this.post('/review-schedule/sync', {})); }
rsList() { return this.revList(); }
rsCreate(d) { return this.revCreate(d); }
rsUpdate(id, d) { return this.revUpdate(id, d); }
rsDelete(id) { return this.revDelete(id); }
rsComplete(id) { return this.revComplete(id); }
rsSync() { return this.revSync(); }
// ═══════════════════════════════════════════════════════════════════
// Stakeholder estesi (A4 Fase 4.5) — riusa suppliers (GV.SC-02).
// stakeholderMap = vista di sintesi raggruppata (supplier/customer/partner). _acn.
// Per aggiungere/etichettare clienti/partner riusare createSupplier/updateSupplier
// passando `stakeholder_type` nel data (NB: NON sono _acn → gestire r.success).
// ═══════════════════════════════════════════════════════════════════
stakeholderMap() { return this._acn(this.get('/supply-chain/stakeholder-map')); }
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// Dashboard // Dashboard
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
+2
View File
@@ -198,10 +198,12 @@ function loadSidebar() {
{ name: 'Organigramma', href: 'organigramma.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M8 2.5h4a.5.5 0 01.5.5v2a.5.5 0 01-.5.5h-1.25v2.5H15a.5.5 0 01.5.5v1.25H17a.5.5 0 01.5.5v3a.5.5 0 01-.5.5h-4a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5h1.25V9.5H6v1.25H7.5a.5.5 0 01.5.5v3a.5.5 0 01-.5.5h-4a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5H4.5V9.5a.5.5 0 01.5-.5h4.75V6H8a.5.5 0 01-.5-.5V3a.5.5 0 01.5-.5z"/></svg>`, i18nKey: 'nav.org_chart' }, { name: 'Organigramma', href: 'organigramma.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M8 2.5h4a.5.5 0 01.5.5v2a.5.5 0 01-.5.5h-1.25v2.5H15a.5.5 0 01.5.5v1.25H17a.5.5 0 01.5.5v3a.5.5 0 01-.5.5h-4a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5h1.25V9.5H6v1.25H7.5a.5.5 0 01.5.5v3a.5.5 0 01-.5.5h-4a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5H4.5V9.5a.5.5 0 01.5-.5h4.75V6H8a.5.5 0 01-.5-.5V3a.5.5 0 01.5-.5z"/></svg>`, i18nKey: 'nav.org_chart' },
{ name: 'Competenze', href: 'competenze.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 11-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zM12 2a1 1 0 01.967.744L14.146 7.2 17.5 8.134a1 1 0 010 1.732l-3.354.934-1.18 4.455a1 1 0 01-1.933 0L9.854 10.8 6.5 9.866a1 1 0 010-1.732l3.354-.934 1.179-4.456A1 1 0 0112 2z"/></svg>`, i18nKey: 'nav.competences' }, { name: 'Competenze', href: 'competenze.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 11-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zM12 2a1 1 0 01.967.744L14.146 7.2 17.5 8.134a1 1 0 010 1.732l-3.354.934-1.18 4.455a1 1 0 01-1.933 0L9.854 10.8 6.5 9.866a1 1 0 010-1.732l3.354-.934 1.179-4.456A1 1 0 0112 2z"/></svg>`, i18nKey: 'nav.competences' },
{ name: 'Matrice RACI', href: 'raci.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 6a1 1 0 011-1h5a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zm9 0a1 1 0 011-1h3a1 1 0 011 1v6a1 1 0 01-1 1h-3a1 1 0 01-1-1v-6z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.raci' }, { name: 'Matrice RACI', href: 'raci.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 6a1 1 0 011-1h5a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zm9 0a1 1 0 011-1h3a1 1 0 011 1v6a1 1 0 01-1 1h-3a1 1 0 01-1-1v-6z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.raci' },
{ name: 'Scadenziario', href: 'review-schedule.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.review_schedule' },
{ name: 'Rischi', href: 'risks.html', icon: iconShieldExclamation(), i18nKey: 'nav.risks' }, { name: 'Rischi', href: 'risks.html', icon: iconShieldExclamation(), i18nKey: 'nav.risks' },
{ name: 'Incidenti', href: 'incidents.html', icon: iconBell(), i18nKey: 'nav.incidents' }, { name: 'Incidenti', href: 'incidents.html', icon: iconBell(), i18nKey: 'nav.incidents' },
{ name: 'Policy', href: 'policies.html', icon: iconDocumentText(), i18nKey: 'nav.policies' }, { name: 'Policy', href: 'policies.html', icon: iconDocumentText(), i18nKey: 'nav.policies' },
{ name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink(), i18nKey: 'nav.supply_chain' }, { name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink(), i18nKey: 'nav.supply_chain' },
{ name: 'Stakeholder', href: 'stakeholders.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"/><path d="M5.5 7.5a2 2 0 11-2.999.001A2 2 0 015.5 7.5zM17.5 7.5a2 2 0 11-2.999.001A2 2 0 0117.5 7.5z"/></svg>`, i18nKey: 'nav.stakeholders' },
{ name: 'Segnalazioni', href: 'whistleblowing.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>` }, { name: 'Segnalazioni', href: 'whistleblowing.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>` },
{ name: 'Normative', href: 'normative.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-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>` }, { name: 'Normative', href: 'normative.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-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>` },
{ name: 'AI Cross-Analysis', href: 'cross-analysis.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"/></svg>` }, { name: 'AI Cross-Analysis', href: 'cross-analysis.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"/></svg>` },
+88
View File
@@ -317,6 +317,90 @@ const HelpSystem = (function () {
] ]
}, },
// ─── Scadenziario centralizzato (A4 Fase 4.4) ─────────────────
'review-schedule': {
title: 'Guida - Scadenziario',
intro: 'Lo Scadenziario raccoglie in un unico posto le revisioni periodiche di policy, controlli, trattamenti del rischio, ruoli, competenze, inventario, fornitori e misure. Per ciascuna voce indica chi è il responsabile (ruolo), ogni quanto va rivista e quando scade la prossima revisione, segnalandone lo stato. È uno strumento di supporto organizzativo, non un parere legale.',
sections: [
{
heading: 'Stati e badge',
items: [
'<strong>OK (verde)</strong>: la prossima revisione è oltre i 30 giorni.',
'<strong>In scadenza (arancione)</strong>: la prossima revisione cade entro 30 giorni.',
'<strong>Scaduto (rosso)</strong>: la data di prossima revisione è già passata.',
'Lo stato è ricalcolato automaticamente in base alla data odierna: non serve aggiornarlo a mano.'
]
},
{
heading: 'Come si usa',
items: [
'<strong>Nuova scadenza</strong>: crei una voce indicando titolo, tipo di entità, ruolo responsabile, frequenza (in mesi) e data della prossima revisione.',
'<strong>Segna revisionato</strong>: registra la revisione di oggi; se è impostata una frequenza, la prossima data avanza automaticamente.',
'<strong>Sincronizza scadenze esistenti</strong>: importa (senza duplicare) le scadenze già presenti nel sistema — riesami delle policy, dei controlli e le date dei trattamenti del rischio della tua organizzazione.'
]
},
{
heading: 'Riferimenti normativi',
items: [
'<strong>GV.PO-02</strong>: le politiche di cybersicurezza sono riviste periodicamente e aggiornate.',
'<strong>GV.SC-07</strong>: i rischi e i rapporti con i fornitori sono monitorati e rivisti nel tempo.',
'<strong>PR.AT</strong>: la formazione e consapevolezza è mantenuta e ripetuta nel tempo.',
'<strong>DE.CM</strong>: il monitoraggio continuo presuppone controlli rivisti con regolarità.',
'In Italia gli obblighi fanno capo al <strong>D.Lgs. 138/2024</strong> (in particolare l\'art. 24 sulla gestione del rischio). La periodicità puntuale, dove non fissata dalla norma, è buona prassi.'
]
}
],
references: [
'NIST CSF 2.0 / GV.PO-02 - Politiche riviste e aggiornate periodicamente',
'NIST CSF 2.0 / GV.SC-07 - Monitoraggio e revisione dei fornitori',
'NIST CSF 2.0 / PR.AT - Formazione e consapevolezza mantenute nel tempo',
'NIST CSF 2.0 / DE.CM - Monitoraggio continuo',
'Obblighi: art. 24 D.Lgs. 138/2024 (gestione del rischio)',
'NOTA: gli obblighi normativi in Italia derivano dal D.Lgs. 138/2024; lo scadenziario e i framework NIST CSF/ACN sono strumenti di supporto. La periodicità puntuale, dove non fissata, è buona prassi, non un parere legale.'
]
},
// ─── Stakeholder estesi (A4 Fase 4.5) ─────────────────────────
'stakeholders': {
title: 'Guida - Stakeholder',
intro: 'La Mappa Stakeholder è una vista di sintesi delle parti interessate dell\'organizzazione, raggruppate in fornitori, clienti e partner. Riusa i dati del modulo Supply Chain (nessuna duplicazione): il dettaglio e la valutazione dei fornitori restano lì, mentre qui puoi avere il quadro d\'insieme e aggiungere o etichettare clienti e partner. È uno strumento di supporto organizzativo, non un parere legale.',
sections: [
{
heading: 'I tre gruppi',
items: [
'<strong>Fornitori</strong>: chi fornisce beni o servizi (inclusi i fornitori ICT). Il loro dettaglio e la valutazione di sicurezza si gestiscono nel modulo Supply Chain.',
'<strong>Clienti</strong>: le parti a cui l\'organizzazione eroga servizi rilevanti per la continuità.',
'<strong>Partner</strong>: soggetti con cui esistono accordi o collaborazioni rilevanti per la sicurezza.'
]
},
{
heading: 'Come si usa',
items: [
'<strong>Aggiungi stakeholder</strong>: registri nome, tipo (fornitore/cliente/partner), tipo di servizio o relazione, contatto e criticità.',
'Per i fornitori, usa il modulo <strong>Supply Chain</strong> per il dettaglio completo, i questionari e la valutazione del rischio (qui niente duplicati).',
'La vista raggruppata aiuta a documentare ruoli e responsabilità verso tutte le parti interessate, non solo i fornitori.'
]
},
{
heading: 'Riferimenti normativi',
items: [
'<strong>GV.SC-02</strong>: ruoli e responsabilità per la sicurezza della catena di fornitura sono stabiliti verso fornitori, <strong>clienti</strong> e <strong>partner</strong>.',
'<strong>GV.SC-04</strong>: i fornitori sono noti e prioritizzati in base alla criticità (l\'estensione a clienti e partner è coperta da GV.SC-02).',
'<strong>GV.SC-05</strong>: i requisiti di sicurezza sono integrati nei contratti e negli accordi con i fornitori e le altre terze parti rilevanti.',
'<strong>GV.SC-07</strong>: i rischi e i rapporti con le parti interessate sono monitorati e rivisti nel tempo.'
]
}
],
references: [
'NIST CSF 2.0 / GV.SC-02 - Ruoli e responsabilità verso fornitori, clienti e partner',
'NIST CSF 2.0 / GV.SC-04 - Fornitori noti e prioritizzati per criticità (clienti/partner: GV.SC-02)',
'NIST CSF 2.0 / GV.SC-05 - Requisiti di sicurezza negli accordi',
'NIST CSF 2.0 / GV.SC-07 - Monitoraggio e revisione delle parti interessate',
'Obblighi: la gestione della supply chain è prevista dall\'art. 24 del D.Lgs. 138/2024.',
'NOTA: gli obblighi normativi in Italia derivano dal D.Lgs. 138/2024; la mappa stakeholder e i framework NIST CSF/ACN sono strumenti di supporto, non un parere legale.'
]
},
// ─── Risk Management ───────────────────────────────────────── // ─── Risk Management ─────────────────────────────────────────
'risks': { 'risks': {
title: 'Guida - Gestione Rischi', title: 'Guida - Gestione Rischi',
@@ -1161,6 +1245,10 @@ const HelpSystem = (function () {
'competenze': 'competences', 'competenze': 'competences',
'raci.html': 'raci', 'raci.html': 'raci',
'raci': 'raci', 'raci': 'raci',
'review-schedule.html': 'review-schedule',
'review-schedule': 'review-schedule',
'stakeholders.html': 'stakeholders',
'stakeholders': 'stakeholders',
'risks.html': 'risks', 'risks.html': 'risks',
'risks': 'risks', 'risks': 'risks',
'incidents.html': 'incidents', 'incidents.html': 'incidents',
+4
View File
@@ -86,6 +86,10 @@ const I18n = (function () {
'org.new_role': { it: 'Nuovo ruolo', en: 'New role' }, 'org.new_role': { it: 'Nuovo ruolo', en: 'New role' },
'comp.title': { it: 'Competenze', en: 'Skills' }, 'comp.title': { it: 'Competenze', en: 'Skills' },
'raci.title': { it: 'Matrice RACI', en: 'RACI Matrix' }, 'raci.title': { it: 'Matrice RACI', en: 'RACI Matrix' },
'nav.review_schedule': { it: 'Scadenziario', en: 'Review schedule' },
'nav.stakeholders': { it: 'Stakeholder', en: 'Stakeholders' },
'rev.title': { it: 'Scadenziario', en: 'Review Schedule' },
'stk.title': { it: 'Stakeholder', en: 'Stakeholders' },
'nav.risks': { it: 'Rischi', en: 'Risks' }, 'nav.risks': { it: 'Rischi', en: 'Risks' },
'nav.incidents': { it: 'Incidenti', en: 'Incidents' }, 'nav.incidents': { it: 'Incidenti', en: 'Incidents' },
'nav.policies': { it: 'Policy', en: 'Policies' }, 'nav.policies': { it: 'Policy', en: 'Policies' },
+4 -4
View File
@@ -151,8 +151,8 @@
</div> </div>
<!-- Stessa logica JS della pagina live (parita' funzionale assoluta, zero backend). --> <!-- Stessa logica JS della pagina live (parita' funzionale assoluta, zero backend). -->
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. --> 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> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
@@ -162,8 +162,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script src="/js/kb.js"></script> <script src="/js/kb.js"></script>
<script> <script>
// Gate auth + chrome come le altre pagine -bi. // Gate auth + chrome come le altre pagine -bi.
+1 -1
View File
@@ -515,7 +515,7 @@ body { background: var(--bg-main); }
<div id="toast"></div> <div id="toast"></div>
<script src="js/api.js?v=20260619"></script> <script src="js/api.js?v=20260620"></script>
<script> <script>
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
// CONFIG // CONFIG
+2 -2
View File
@@ -94,8 +94,8 @@
</script> </script>
<!-- Stessa logica della login attuale: ZERO modifiche backend --> <!-- Stessa logica della login attuale: ZERO modifiche backend -->
<script src="js/api.js?v=20260619"></script> <script src="js/api.js?v=20260620"></script>
<script src="js/common.js?v=20260619"></script> <script src="js/common.js?v=20260620"></script>
<script> <script>
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; } if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
+1 -1
View File
@@ -424,6 +424,6 @@ curl https://nis2.agile.software/api/services/status</pre>
</div> </div>
</div> </div>
<script src="js/common.js?v=20260619"></script> <script src="js/common.js?v=20260620"></script>
</body> </body>
</html> </html>
+4 -4
View File
@@ -112,8 +112,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -124,8 +124,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar();
+2 -2
View File
@@ -494,8 +494,8 @@
</script> </script>
<!-- Stessa logica della onboarding.html: ZERO modifiche backend --> <!-- Stessa logica della onboarding.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260619"></script> <script src="js/api.js?v=20260620"></script>
<script src="js/common.js?v=20260619"></script> <script src="js/common.js?v=20260620"></script>
<script> <script>
// ── Auth check ────────────────────────────────────────────────── // ── Auth check ──────────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+4 -4
View File
@@ -138,12 +138,12 @@
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script src="/js/organigramma.js?v=20260617"></script> <script src="/js/organigramma.js?v=20260617"></script>
</body> </body>
</html> </html>
+4 -4
View File
@@ -333,8 +333,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -345,8 +345,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+4 -4
View File
@@ -131,12 +131,12 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script src="/js/raci.js?v=20260619"></script> <script src="/js/raci.js?v=20260619"></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -268,8 +268,8 @@
</script> </script>
<!-- Stessa logica della register.html: ZERO modifiche backend --> <!-- Stessa logica della register.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260619"></script> <script src="js/api.js?v=20260620"></script>
<script src="js/common.js?v=20260619"></script> <script src="js/common.js?v=20260620"></script>
<script> <script>
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; } if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
+4 -4
View File
@@ -443,8 +443,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -455,8 +455,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+348
View File
@@ -0,0 +1,348 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Scadenziario - 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=20260617">
<style>
.rev-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:16px; font-size:.92rem; line-height:1.6; }
.rev-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 18px; }
.rev-stats { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:18px; }
.rev-stat { flex:1 1 150px; min-width:130px; background:#fff; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; padding:12px 14px; }
.rev-stat .n { font-size:1.5rem; font-weight:800; line-height:1; }
.rev-stat .l { font-size:.78rem; color:var(--gray-500,#6b7280); margin-top:4px; }
.rev-stat.s-overdue .n { color:#991b1b; }
.rev-stat.s-due .n { color:#b45309; }
.rev-stat.s-ok .n { color:#166534; }
.rev-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.rev-toolbar select { padding:8px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.88rem; }
.rev-toolbar .grow { flex:1 1 auto; }
.rev-table { width:100%; border-collapse:collapse; font-size:.88rem; }
.rev-table th, .rev-table td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
.rev-table th { font-size:.74rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
.rev-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; }
.rev-badge.type { background:#f1f5f9; color:#475569; }
.gap-pill { display:inline-block; font-size:.72rem; font-weight:800; padding:2px 9px; border-radius:20px; }
.gap-pill.ok { background:#dcfce7; color:#166534; }
.gap-pill.due { background:#fef3c7; color:#92400e; }
.gap-pill.bad { background:#fee2e2; color:#991b1b; }
.rev-actions-cell { display:flex; gap:6px; flex-wrap:wrap; }
.rev-actions-cell .btn, .rev-toolbar .btn { padding:4px 9px; font-size:.76rem; }
.rev-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); }
/* Modale */
.rm-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:40px 16px; }
.rm-overlay.open { display:flex; }
.rm-dialog { background:#fff; border-radius:12px; width:100%; max-width:560px; box-shadow:0 20px 50px rgba(0,0,0,.25); }
.rm-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
.rm-head h3 { margin:0; font-size:1.05rem; }
.rm-body { padding:18px 20px; }
.rm-foot { padding:14px 20px; border-top:1px solid var(--gray-100,#f3f4f6); display:flex; justify-content:flex-end; gap:10px; }
.rm-field { margin-bottom:14px; }
.rm-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; }
.rm-field input[type=text], .rm-field input[type=number], .rm-field input[type=date], .rm-field select, .rm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; }
.rm-field textarea { min-height:64px; resize:vertical; }
.rm-row { display:flex; gap:12px; }
.rm-row > .rm-field { flex:1 1 0; }
.rm-err { color:#b91c1c; font-size:.82rem; min-height:18px; }
.rm-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="rev.title">Scadenziario</h2>
<div class="content-header-actions">
<span class="savehint" id="rev-hint" role="status" aria-live="polite"></span>
</div>
</header>
<div class="content-body">
<div class="rev-intro">
<strong>Revisioni e scadenze in un unico posto.</strong>
Tieni traccia delle revisioni periodiche di ruoli, competenze, inventario, procedure, rischi, fornitori e misure. Ogni voce mostra quando è prevista la prossima revisione e ti avvisa quando è in scadenza o scaduta.
</div>
<div class="rev-note">
Ancoraggio: <strong>GV.PO-02</strong> (riesame periodico delle policy), <strong>GV.SC-07</strong> (revisione dei fornitori), <strong>PR.AT</strong>, <strong>DE.CM</strong> (monitoraggio continuo). Gli obblighi di gestione del rischio fanno capo all'<strong>art. 24 D.Lgs. 138/2024</strong>. La periodicità puntuale è una buona prassi dove non fissata da norma o contratto. Strumento di supporto, non un parere legale.
</div>
<div class="rev-stats" id="rev-stats" style="display:none;">
<div class="rev-stat"><div class="n" id="rev-stat-total">0</div><div class="l">Voci totali</div></div>
<div class="rev-stat s-overdue"><div class="n" id="rev-stat-overdue">0</div><div class="l">Scadute</div></div>
<div class="rev-stat s-due"><div class="n" id="rev-stat-due">0</div><div class="l">In scadenza (30 gg)</div></div>
<div class="rev-stat s-ok"><div class="n" id="rev-stat-ok">0</div><div class="l">In regola</div></div>
</div>
<div class="rev-toolbar">
<button class="btn btn-primary btn-sm" onclick="revOpen()">+ Nuova scadenza</button>
<button class="btn btn-outline btn-sm" id="rev-sync-btn" onclick="revDoSync()">Sincronizza scadenze esistenti</button>
<span class="grow"></span>
<label for="rev-filter" style="font-weight:600;font-size:.82rem;">Stato:</label>
<select id="rev-filter" onchange="revRender()">
<option value="all">Tutti</option>
<option value="overdue">Scadute</option>
<option value="due">In scadenza</option>
<option value="ok">In regola</option>
</select>
</div>
<div id="rev-list" aria-live="polite"></div>
</div>
</main>
</div>
<!-- Modale scadenza (crea/modifica) -->
<div class="rm-overlay" id="rev-modal" role="dialog" aria-modal="true" aria-labelledby="rev-modal-title">
<div class="rm-dialog">
<div class="rm-head"><h3 id="rev-modal-title">Nuova scadenza</h3><button class="rm-close" type="button" onclick="revClose()" aria-label="Chiudi">&times;</button></div>
<div class="rm-body">
<input type="hidden" id="rev-id">
<div class="rm-field"><label for="rev-f-title">Titolo *</label><input type="text" id="rev-f-title" maxlength="255" placeholder="es. Riesame annuale della policy di backup"></div>
<div class="rm-row">
<div class="rm-field"><label for="rev-f-type">Tipo entità</label>
<select id="rev-f-type">
<option value="custom">Personalizzata</option>
<option value="role">Ruolo</option>
<option value="skill">Competenza</option>
<option value="inventory">Inventario</option>
<option value="procedure">Procedura</option>
<option value="risk">Rischio</option>
<option value="supplier">Fornitore</option>
<option value="measure">Misura</option>
</select>
</div>
<div class="rm-field"><label for="rev-f-owner">Owner (ruolo)</label><select id="rev-f-owner"><option value="">— nessuno —</option></select></div>
</div>
<div class="rm-row">
<div class="rm-field"><label for="rev-f-next">Prossima revisione *</label><input type="date" id="rev-f-next"></div>
<div class="rm-field"><label for="rev-f-freq">Frequenza (mesi)</label><input type="number" id="rev-f-freq" min="1" max="120" placeholder="es. 12"></div>
</div>
<div class="rm-field"><label for="rev-f-notes">Note</label><textarea id="rev-f-notes" maxlength="2000"></textarea></div>
<div class="rm-err" id="rev-err" role="alert"></div>
</div>
<div class="rm-foot"><button class="btn btn-outline" type="button" onclick="revClose()">Annulla</button><button class="btn btn-primary" type="button" id="rev-save" onclick="revSave()">Salva</button></div>
</div>
</div>
<script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260620"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260620"></script>
<script>
'use strict';
/*
* NIS2 Agile - Scadenziario centralizzato (A4 Fase 4.4)
* review_schedule: revisioni periodiche di ruoli/competenze/inventario/procedure/rischi/fornitori/misure.
* Client api.rev* -> _acn (ritorna data, lancia su success=false).
* Ancoraggio GV.PO-02, GV.SC-07, PR.AT, DE.CM. Obblighi: art. 24 D.Lgs. 138/2024.
*/
let REV = { items: [], roles: [] };
function el(id) { return document.getElementById(id); }
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
const TYPE_LABELS = {
custom: 'Personalizzata', role: 'Ruolo', skill: 'Competenza', inventory: 'Inventario',
procedure: 'Procedura', risk: 'Rischio', supplier: 'Fornitore', measure: 'Misura'
};
const STATUS_META = {
ok: { cls: 'ok', label: 'In regola' },
due: { cls: 'due', label: 'In scadenza' },
overdue: { cls: 'bad', label: 'Scaduta' }
};
function typeLabel(t) { return TYPE_LABELS[t] || (t || '—'); }
function fmtDate(d) {
if (!d) return '—';
try { return new Date(d + 'T00:00:00').toLocaleDateString('it-IT'); } catch (e) { return esc(d); }
}
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') revClose(); });
const ov = el('rev-modal');
if (ov) ov.addEventListener('click', function (e) { if (e.target === ov) revClose(); });
await revLoadShared();
await revLoad();
});
async function revLoadShared() {
try {
const rolesData = await api.orgRolesList();
REV.roles = (rolesData && rolesData.roles) || [];
revFillOwnerSelect();
} catch (e) {
// l'organigramma può non essere ancora popolato: non bloccare la pagina
REV.roles = [];
}
}
function revFillOwnerSelect() {
const sel = el('rev-f-owner');
let h = '<option value="">— nessuno —</option>';
REV.roles.forEach(function (r) {
h += '<option value="' + r.id + '">' + esc(r.role_name || r.name || ('Ruolo #' + r.id)) + '</option>';
});
sel.innerHTML = h;
}
async function revLoad() {
el('rev-list').innerHTML = '<div class="rev-empty">Caricamento…</div>';
try {
const data = await api.revList();
REV.items = (data && data.items) || [];
revRenderStats(data);
revRender();
} catch (e) {
el('rev-list').innerHTML = '<div class="rev-empty">Errore nel caricamento: ' + esc(e.message || e) + '</div>';
}
}
function revRenderStats(data) {
const total = (data && data.total != null) ? data.total : REV.items.length;
const overdue = (data && data.overdue != null) ? data.overdue : REV.items.filter(i => i.status === 'overdue').length;
const due = (data && data.due != null) ? data.due : REV.items.filter(i => i.status === 'due').length;
const ok = (data && data.ok != null) ? data.ok : REV.items.filter(i => i.status === 'ok').length;
el('rev-stat-total').textContent = total;
el('rev-stat-overdue').textContent = overdue;
el('rev-stat-due').textContent = due;
el('rev-stat-ok').textContent = ok;
el('rev-stats').style.display = total ? 'flex' : 'none';
}
function revRender() {
const filter = el('rev-filter').value;
const rows = REV.items.filter(i => filter === 'all' || i.status === filter);
if (!REV.items.length) {
el('rev-list').innerHTML = '<div class="rev-empty">Nessuna scadenza registrata. Aggiungine una con "+ Nuova scadenza" oppure importa quelle esistenti con "Sincronizza scadenze esistenti".</div>';
return;
}
if (!rows.length) {
el('rev-list').innerHTML = '<div class="rev-empty">Nessuna voce per il filtro selezionato.</div>';
return;
}
const body = rows.map(function (i) {
const meta = STATUS_META[i.status] || STATUS_META.ok;
const owner = i.owner_role_name ? esc(i.owner_role_name) : '—';
const freq = (i.frequency_months != null && i.frequency_months !== '') ? (esc(i.frequency_months) + ' mesi') : '';
return '<tr>'
+ '<td><strong>' + esc(i.title) + '</strong>'
+ (i.notes ? '<div class="gap-role-sub" style="font-size:.8rem;color:var(--gray-500,#6b7280);margin-top:3px;">' + esc(i.notes) + '</div>' : '') + '</td>'
+ '<td><span class="rev-badge type">' + esc(typeLabel(i.entity_type)) + '</span></td>'
+ '<td>' + owner + '</td>'
+ '<td>' + fmtDate(i.last_reviewed_at) + '</td>'
+ '<td>' + fmtDate(i.next_review_date) + (freq ? '<div style="font-size:.78rem;color:var(--gray-500,#6b7280);">ogni ' + freq + '</div>' : '') + '</td>'
+ '<td><span class="gap-pill ' + meta.cls + '">' + meta.label + '</span></td>'
+ '<td><div class="rev-actions-cell">'
+ '<button class="btn btn-outline" onclick="revComplete(' + i.id + ')">Segna revisionato</button>'
+ '<button class="btn btn-outline" onclick="revOpen(' + i.id + ')">Modifica</button>'
+ '<button class="btn btn-outline" onclick="revDelete(' + i.id + ')">Elimina</button>'
+ '</div></td>'
+ '</tr>';
}).join('');
el('rev-list').innerHTML = '<table class="rev-table"><thead><tr>'
+ '<th>Titolo</th><th>Tipo</th><th>Owner</th><th>Ultima revisione</th><th>Prossima revisione</th><th>Stato</th><th>Azioni</th>'
+ '</tr></thead><tbody>' + body + '</tbody></table>';
}
function revOpen(id) {
const i = id ? REV.items.find(x => x.id === id) : null;
el('rev-modal-title').textContent = i ? 'Modifica scadenza' : 'Nuova scadenza';
el('rev-id').value = i ? i.id : '';
el('rev-f-title').value = i ? (i.title || '') : '';
el('rev-f-type').value = i ? (i.entity_type || 'custom') : 'custom';
el('rev-f-owner').value = i && i.owner_role_id != null ? String(i.owner_role_id) : '';
el('rev-f-next').value = i ? (i.next_review_date || '') : '';
el('rev-f-freq').value = i && i.frequency_months != null ? i.frequency_months : '';
el('rev-f-notes').value = i ? (i.notes || '') : '';
el('rev-err').textContent = '';
el('rev-modal').classList.add('open');
setTimeout(() => el('rev-f-title').focus(), 50);
}
function revClose() { el('rev-modal').classList.remove('open'); }
async function revSave() {
const id = el('rev-id').value;
const title = el('rev-f-title').value.trim();
const next = el('rev-f-next').value;
if (!title) { el('rev-err').textContent = 'Il titolo è obbligatorio.'; return; }
if (!next) { el('rev-err').textContent = 'La data di prossima revisione è obbligatoria.'; return; }
const freqRaw = el('rev-f-freq').value.trim();
const ownerRaw = el('rev-f-owner').value;
const payload = {
title: title,
entity_type: el('rev-f-type').value,
owner_role_id: ownerRaw ? parseInt(ownerRaw, 10) : null,
frequency_months: freqRaw ? parseInt(freqRaw, 10) : null,
next_review_date: next,
notes: el('rev-f-notes').value.trim()
};
const btn = el('rev-save'); btn.disabled = true;
try {
if (id) { await api.revUpdate(parseInt(id, 10), payload); showNotification('Scadenza aggiornata.', 'success'); }
else { await api.revCreate(payload); showNotification('Scadenza creata.', 'success'); }
revClose();
await revLoad();
} catch (e) {
el('rev-err').textContent = e.message || 'Errore nel salvataggio.';
} finally { btn.disabled = false; }
}
async function revComplete(id) {
try {
await api.revComplete(id);
showNotification('Revisione registrata.', 'success');
await revLoad();
} catch (e) { showNotification('Errore: ' + (e.message || e), 'error'); }
}
async function revDelete(id) {
const i = REV.items.find(x => x.id === id);
if (!confirm('Eliminare la scadenza "' + (i ? i.title : '') + '"?')) return;
try {
await api.revDelete(id);
showNotification('Scadenza eliminata.', 'success');
await revLoad();
} catch (e) { showNotification('Errore: ' + (e.message || e), 'error'); }
}
async function revDoSync() {
const btn = el('rev-sync-btn'); btn.disabled = true;
try {
const r = await api.revSync();
const created = (r && r.created != null) ? r.created : 0;
const updated = (r && r.updated != null) ? r.updated : 0;
const skipped = (r && r.skipped != null) ? r.skipped : 0;
showNotification('Sincronizzazione completata: ' + created + ' aggiunte, ' + updated + ' aggiornate, ' + skipped + ' già presenti.', 'success');
await revLoad();
} catch (e) {
showNotification('Errore nella sincronizzazione: ' + (e.message || e), 'error');
} finally { btn.disabled = false; }
}
</script>
</body>
</html>
+4 -4
View File
@@ -494,8 +494,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -506,8 +506,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
// ── Auth & Init ────────────────────────────────────────────── // ── Auth & Init ──────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+4 -4
View File
@@ -672,8 +672,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -684,8 +684,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+2 -2
View File
@@ -165,8 +165,8 @@
</main> </main>
</div> </div>
<script src="js/api.js?v=20260619"></script> <script src="js/api.js?v=20260620"></script>
<script src="js/common.js?v=20260619"></script> <script src="js/common.js?v=20260620"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+278
View File
@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<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=20260617">
<style>
.stk-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:16px; 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 18px; }
.stk-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.stk-toolbar .grow { flex:1 1 auto; }
.stk-toolbar .btn { padding:4px 12px; font-size:.8rem; }
.stk-group { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; margin-bottom:14px; overflow:hidden; }
.stk-group-head { background:var(--gray-50,#f9fafb); padding:11px 14px; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:6px; }
.stk-group-title { font-weight:700; font-size:.98rem; }
.stk-count { display:inline-block; font-size:.72rem; font-weight:800; padding:2px 10px; border-radius:20px; background:#e0f2fe; color:#075985; margin-left:8px; }
.stk-table { width:100%; border-collapse:collapse; font-size:.88rem; }
.stk-table th, .stk-table td { text-align:left; padding:9px 12px; border-top:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
.stk-table th { font-size:.74rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
.stk-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; }
.stk-badge.crit-low { background:#dcfce7; color:#166534; }
.stk-badge.crit-medium { background:#fef3c7; color:#92400e; }
.stk-badge.crit-high { background:#ffedd5; color:#9a3412; }
.stk-badge.crit-critical { background:#fee2e2; color:#991b1b; }
.stk-badge.st { background:#f1f5f9; color:#475569; }
.stk-empty { text-align:center; padding:22px 16px; color:var(--gray-500,#6b7280); font-size:.86rem; }
.stk-link { font-size:.78rem; }
/* Modale */
.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:40px 16px; }
.sm-overlay.open { display:flex; }
.sm-dialog { background:#fff; border-radius:12px; width:100%; max-width:560px; 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; }
.sm-field { margin-bottom:14px; }
.sm-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; }
.sm-field input[type=text], .sm-field input[type=email], .sm-field select, .sm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; }
.sm-field textarea { min-height:64px; resize:vertical; }
.sm-row { display:flex; gap:12px; }
.sm-row > .sm-field { flex:1 1 0; }
.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); }
</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="stk.title">Stakeholder</h2>
<div class="content-header-actions">
<button class="btn btn-primary btn-sm" onclick="stkOpen()">+ Aggiungi stakeholder</button>
</div>
</header>
<div class="content-body">
<div class="stk-intro">
<strong>Mappa degli stakeholder.</strong>
Vista di sintesi di fornitori, clienti e partner della tua organizzazione, raggruppati per tipo. Riusa i dati della Supply Chain: il dettaglio e la valutazione dei fornitori restano nel modulo dedicato.
</div>
<div class="stk-note">
Ancoraggio: <strong>GV.SC-02</strong> (ruoli e responsabilità definiti verso fornitori, clienti e partner) con <strong>GV.SC-04/05/07</strong>. Gli obblighi di gestione del rischio della catena di fornitura fanno capo all'<strong>art. 24 D.Lgs. 138/2024</strong>. Strumento di supporto, non un parere legale.
</div>
<div class="stk-toolbar">
<span class="grow"></span>
<a class="btn btn-outline btn-sm" href="supply-chain.html" data-i18n="stk.manage_suppliers">Gestisci fornitori in Supply Chain</a>
</div>
<div id="stk-groups" aria-live="polite"></div>
</div>
</main>
</div>
<!-- Modale stakeholder (crea/modifica) -->
<div class="sm-overlay" id="stk-modal" role="dialog" aria-modal="true" aria-labelledby="stk-modal-title">
<div class="sm-dialog">
<div class="sm-head"><h3 id="stk-modal-title">Aggiungi stakeholder</h3><button class="sm-close" type="button" onclick="stkClose()" aria-label="Chiudi">&times;</button></div>
<div class="sm-body">
<input type="hidden" id="stk-id">
<div class="sm-field"><label for="stk-f-name">Nome *</label><input type="text" id="stk-f-name" maxlength="255" placeholder="es. Acme Cloud S.p.A."></div>
<div class="sm-row">
<div class="sm-field"><label for="stk-f-type">Tipo</label>
<select id="stk-f-type" onchange="stkOnTypeChange()">
<option value="supplier">Fornitore</option>
<option value="customer">Cliente</option>
<option value="partner">Partner</option>
</select>
</div>
<div class="sm-field"><label for="stk-f-service">Servizio / relazione *</label><input type="text" id="stk-f-service" maxlength="255" placeholder="es. Hosting, consulenza, contratto quadro"></div>
</div>
<div class="sm-row">
<div class="sm-field"><label for="stk-f-crit">Criticità</label>
<select id="stk-f-crit">
<option value="low">Bassa</option>
<option value="medium" selected>Media</option>
<option value="high">Alta</option>
<option value="critical">Critica</option>
</select>
</div>
<div class="sm-field"><label for="stk-f-contact-name">Nome contatto</label><input type="text" id="stk-f-contact-name" maxlength="255"></div>
</div>
<div class="sm-field"><label for="stk-f-contact-email">Email contatto</label><input type="email" id="stk-f-contact-email" maxlength="255" placeholder="referente@esempio.it"></div>
<div class="sm-err" id="stk-err" role="alert"></div>
</div>
<div class="sm-foot"><button class="btn btn-outline" type="button" onclick="stkClose()">Annulla</button><button class="btn btn-primary" type="button" id="stk-save" onclick="stkSave()">Salva</button></div>
</div>
</div>
<script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260620"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260620"></script>
<script>
'use strict';
/*
* NIS2 Agile - Mappa stakeholder (A4 Fase 4.5)
* Vista di sintesi fornitori/clienti/partner: riusa la tabella suppliers (stakeholder_type).
* Lettura via api.stakeholderMap() (_acn). Creazione/etichettatura via api.createSupplier/updateSupplier
* (NON _acn -> ritornano {success,...}). Ancoraggio GV.SC-02 + GV.SC-04/05/07. Obblighi: art. 24 D.Lgs. 138/2024.
*/
let STK = { groups: { supplier: [], customer: [], partner: [] }, counts: {} };
function el(id) { return document.getElementById(id); }
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
const TYPE_TITLE = { supplier: 'Fornitori', customer: 'Clienti', partner: 'Partner' };
const TYPE_ORDER = ['supplier', 'customer', 'partner'];
const CRIT_LABELS = { low: 'Bassa', medium: 'Media', high: 'Alta', critical: 'Critica' };
const STATUS_LABELS = { active: 'Attivo', under_review: 'In revisione', suspended: 'Sospeso', terminated: 'Cessato' };
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') stkClose(); });
const ov = el('stk-modal');
if (ov) ov.addEventListener('click', function (e) { if (e.target === ov) stkClose(); });
await stkLoad();
});
async function stkLoad() {
el('stk-groups').innerHTML = '<div class="stk-empty">Caricamento…</div>';
try {
const data = await api.stakeholderMap();
STK.groups = (data && data.groups) || { supplier: [], customer: [], partner: [] };
STK.counts = (data && data.counts) || {};
stkRender();
} catch (e) {
el('stk-groups').innerHTML = '<div class="stk-empty">Errore nel caricamento: ' + esc(e.message || e) + '</div>';
}
}
function stkRender() {
const total = TYPE_ORDER.reduce((n, t) => n + ((STK.groups[t] || []).length), 0);
if (!total) {
el('stk-groups').innerHTML = '<div class="stk-empty">Nessuno stakeholder registrato. Aggiungine uno con "+ Aggiungi stakeholder". I fornitori inseriti dalla Supply Chain compaiono automaticamente qui.</div>';
return;
}
el('stk-groups').innerHTML = TYPE_ORDER.map(stkGroupHtml).join('');
}
function stkGroupHtml(type) {
const items = STK.groups[type] || [];
const count = STK.counts[type] != null ? STK.counts[type] : items.length;
let inner;
if (!items.length) {
inner = '<div class="stk-empty">Nessun ' + esc(TYPE_TITLE[type].toLowerCase().replace(/i$/, 'e')) + ' registrato.</div>';
} else {
const rows = items.map(function (s) {
const crit = s.criticality || 'medium';
const isSupplier = (s.stakeholder_type || 'supplier') === 'supplier';
const actions = '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="stkOpen(' + s.id + ')">Modifica</button>'
+ (isSupplier ? ' <a class="stk-link" href="supply-chain.html">Dettaglio in Supply Chain</a>' : '');
return '<tr>'
+ '<td><strong>' + esc(s.name) + '</strong>'
+ (s.contact_name || s.contact_email ? '<div style="font-size:.78rem;color:var(--gray-500,#6b7280);margin-top:2px;">' + esc(s.contact_name || '') + (s.contact_email ? ' · ' + esc(s.contact_email) : '') + '</div>' : '') + '</td>'
+ '<td>' + (s.service_type ? esc(s.service_type) : '—') + '</td>'
+ '<td><span class="stk-badge crit-' + esc(crit) + '">' + esc(CRIT_LABELS[crit] || crit) + '</span></td>'
+ '<td><span class="stk-badge st">' + esc(STATUS_LABELS[s.status] || s.status || '—') + '</span></td>'
+ '<td>' + actions + '</td>'
+ '</tr>';
}).join('');
inner = '<table class="stk-table"><thead><tr>'
+ '<th>Nome</th><th>Servizio / relazione</th><th>Criticità</th><th>Stato</th><th>Azioni</th>'
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
}
return '<div class="stk-group">'
+ '<div class="stk-group-head"><div class="stk-group-title">' + esc(TYPE_TITLE[type]) + '<span class="stk-count">' + count + '</span></div></div>'
+ inner + '</div>';
}
function stkAllItems() {
return TYPE_ORDER.reduce((acc, t) => acc.concat(STK.groups[t] || []), []);
}
function stkOpen(id) {
const s = id ? stkAllItems().find(x => x.id === id) : null;
el('stk-modal-title').textContent = s ? 'Modifica stakeholder' : 'Aggiungi stakeholder';
el('stk-id').value = s ? s.id : '';
el('stk-f-name').value = s ? (s.name || '') : '';
el('stk-f-type').value = s ? (s.stakeholder_type || 'supplier') : 'supplier';
el('stk-f-service').value = s ? (s.service_type || '') : '';
el('stk-f-crit').value = s ? (s.criticality || 'medium') : 'medium';
el('stk-f-contact-name').value = s ? (s.contact_name || '') : '';
el('stk-f-contact-email').value = s ? (s.contact_email || '') : '';
el('stk-err').textContent = '';
el('stk-modal').classList.add('open');
setTimeout(() => el('stk-f-name').focus(), 50);
}
function stkClose() { el('stk-modal').classList.remove('open'); }
// Per clienti/partner il servizio è meno ovvio: precompila un default ragionevole se vuoto.
function stkOnTypeChange() {
const t = el('stk-f-type').value;
const svc = el('stk-f-service');
if (!svc.value.trim()) {
if (t === 'customer') svc.value = 'Cliente';
else if (t === 'partner') svc.value = 'Partner';
}
}
async function stkSave() {
const id = el('stk-id').value;
const name = el('stk-f-name').value.trim();
let service = el('stk-f-service').value.trim();
const type = el('stk-f-type').value;
if (!name) { el('stk-err').textContent = 'Il nome è obbligatorio.'; return; }
// service_type è obbligatorio lato backend: forziamo un default coerente per clienti/partner.
if (!service) {
service = type === 'customer' ? 'Cliente' : (type === 'partner' ? 'Partner' : '');
}
if (!service) { el('stk-err').textContent = 'Indica il servizio o la relazione.'; return; }
const payload = {
name: name,
stakeholder_type: type,
service_type: service,
criticality: el('stk-f-crit').value,
contact_name: el('stk-f-contact-name').value.trim(),
contact_email: el('stk-f-contact-email').value.trim()
};
const btn = el('stk-save'); btn.disabled = true;
try {
const r = id ? await api.updateSupplier(parseInt(id, 10), payload) : await api.createSupplier(payload);
if (r && r.success === false) { el('stk-err').textContent = r.message || 'Errore nel salvataggio.'; return; }
showNotification(id ? 'Stakeholder aggiornato.' : 'Stakeholder aggiunto.', 'success');
stkClose();
await stkLoad();
} catch (e) {
el('stk-err').textContent = (e && e.message) ? e.message : 'Errore nel salvataggio.';
} finally { btn.disabled = false; }
}
</script>
</body>
</html>
+4 -4
View File
@@ -477,8 +477,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -489,8 +489,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+4 -4
View File
@@ -292,8 +292,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -304,8 +304,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+1 -1
View File
@@ -1 +1 @@
{"version": "1.17.1", "build": "2026-06-15-v1.17.1", "date": "2026-06-15", "changelog": "A4 Fase 4.3 — Matrice RACI (ruoli x oggetti) + link molti-a-molti procedure/inventario/rischi/misure ACN. Nuove tabelle raci_assignments + procedure_inventory/procedure_risk/inventory_risk/risk_measure (mig.043). RaciController org-scoped (anti-IDOR), pagina raci.html (Bootstrap Italia/AGID), help+i18n. Nessuna modifica a dati esistenti."} {"version": "1.17.2", "build": "2026-06-15-v1.17.2", "date": "2026-06-15", "changelog": "A4 Fasi 4.4 (scadenziario centralizzato review_schedule: list/complete/sync, mig.044) e 4.5 (stakeholder estesi a clienti/partner GV.SC-02: suppliers.stakeholder_type, stakeholderMap, mig.045) - completano A4 (5/5 fasi). Nuove pagine review-schedule.html, stakeholders.html. Nessuna modifica a dati esistenti."}
+4 -4
View File
@@ -207,8 +207,8 @@
<!-- Report Detail Modal handled by common.js showModal --> <!-- Report Detail Modal handled by common.js showModal -->
<script src="/js/api.js?v=20260619"></script> <script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260619"></script> <script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -219,8 +219,8 @@
} }
</script> </script>
<script src="/js/common-bi.js?v=20260618"></script> <script src="/js/common-bi.js?v=20260618"></script>
<script src="/js/i18n.js?v=20260619"></script> <script src="/js/i18n.js?v=20260620"></script>
<script src="/js/help.js?v=20260619"></script> <script src="/js/help.js?v=20260620"></script>
<script> <script>
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar();
+3 -3
View File
@@ -319,9 +319,9 @@
</main> </main>
</div> </div>
<script src="js/api.js?v=20260619"></script> <script src="js/api.js?v=20260620"></script>
<script src="js/common.js?v=20260619"></script> <script src="js/common.js?v=20260620"></script>
<script src="js/i18n.js?v=20260619"></script> <script src="js/i18n.js?v=20260620"></script>
<script> <script>
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar();
+78 -7
View File
@@ -8,23 +8,70 @@
* USO (sull'host, con la connessione dell'app): * USO (sull'host, con la connessione dell'app):
* docker exec nis2-app php /var/www/nis2-agile/scripts/migrate-a4.php * docker exec nis2-app php /var/www/nis2-agile/scripts/migrate-a4.php
* *
* Idempotente: le migrazioni usano CREATE TABLE IF NOT EXISTS. * Idempotente: le migrazioni 041-044 usano CREATE TABLE IF NOT EXISTS; la 045
* Sola lettura/additivo su tabelle esistenti: NON modifica nessuna tabella * usa ALTER TABLE nude (MySQL 8 non ha ADD COLUMN/INDEX IF NOT EXISTS) e
* preesistente. Stesso pattern di scripts/migrate-isms.php. * l'idempotenza la garantisce shouldSkipAlter() con pre-check su
* information_schema (stesso pattern di mig.040_onboarding_classification.sql).
* Tutti i file SQL restano RUNNER-SAFE: solo statement nudi, niente DELIMITER /
* stored procedure, nessun ';' dentro commenti o stringhe (lo split avviene su ';').
* Stesso pattern di scripts/migrate-isms.php.
*/ */
require_once __DIR__ . '/../application/config/config.php'; require_once __DIR__ . '/../application/config/config.php';
require_once __DIR__ . '/../application/config/database.php'; require_once __DIR__ . '/../application/config/database.php';
$files = [ $files = [
__DIR__ . '/../docs/sql/041_org_roles.sql', // Fase 4.1 — organigramma __DIR__ . '/../docs/sql/041_org_roles.sql', // Fase 4.1 — organigramma
__DIR__ . '/../docs/sql/042_competences.sql', // Fase 4.2 — competenze __DIR__ . '/../docs/sql/042_competences.sql', // Fase 4.2 — competenze
// Le fasi 4.3-4.5 aggiungeranno qui i rispettivi file (043, ...). __DIR__ . '/../docs/sql/043_raci.sql', // Fase 4.3 — matrice RACI + link m2m
__DIR__ . '/../docs/sql/044_review_schedule.sql', // Fase 4.4 — scadenziario revisioni
__DIR__ . '/../docs/sql/045_stakeholders.sql', // Fase 4.5 — stakeholder estesi (ALTER suppliers)
]; ];
$pdo = Database::getInstance(); $pdo = Database::getInstance();
$totalStmts = 0; $totalStmts = 0;
/**
* Pre-check di idempotenza per gli statement ALTER non nativamente idempotenti su
* MySQL 8 (che NON supporta ADD COLUMN/INDEX IF NOT EXISTS). Ritorna true se lo
* statement va SALTATO perche' la colonna/indice esiste gia'. Stesso pattern di
* mig.040_onboarding_classification.sql: il .sql resta con statement nudi
* (runner-safe, niente DELIMITER) e l'idempotenza la garantisce qui il runner.
*/
function shouldSkipAlter(PDO $pdo, string $stmt): bool {
// ADD COLUMN <name>
if (preg_match('/^ALTER\s+TABLE\s+`?(\w+)`?\s+ADD\s+COLUMN\s+`?(\w+)`?/i', $stmt, $m)) {
$table = $m[1];
$col = $m[2];
$st = $pdo->prepare(
'SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
);
$st->execute([$table, $col]);
if ($st->fetchColumn()) {
echo " [skip] colonna gia presente: {$table}.{$col}\n";
return true;
}
return false;
}
// ADD INDEX <name>
if (preg_match('/^ALTER\s+TABLE\s+`?(\w+)`?\s+ADD\s+(?:INDEX|KEY)\s+`?(\w+)`?/i', $stmt, $m)) {
$table = $m[1];
$idx = $m[2];
$st = $pdo->prepare(
'SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?'
);
$st->execute([$table, $idx]);
if ($st->fetchColumn()) {
echo " [skip] indice gia presente: {$table}.{$idx}\n";
return true;
}
return false;
}
return false;
}
foreach ($files as $file) { foreach ($files as $file) {
if (!is_readable($file)) { if (!is_readable($file)) {
fwrite(STDERR, "[migrate-a4] File non leggibile: $file\n"); fwrite(STDERR, "[migrate-a4] File non leggibile: $file\n");
@@ -48,11 +95,17 @@ foreach ($files as $file) {
} }
$sql = implode("\n", $clean); $sql = implode("\n", $clean);
// Split semplice su ';' (nessun DELIMITER / stored procedure in questi file). // Split semplice su ';' (nessun DELIMITER / stored procedure in questi file:
// 045 usa ALTER nude, l'idempotenza e' nel pre-check shouldSkipAlter()).
$statements = array_filter(array_map('trim', explode(';', $sql)), fn($s) => $s !== ''); $statements = array_filter(array_map('trim', explode(';', $sql)), fn($s) => $s !== '');
echo "== " . basename($file) . " (" . count($statements) . " statement) ==\n"; echo "== " . basename($file) . " (" . count($statements) . " statement) ==\n";
foreach ($statements as $stmt) { foreach ($statements as $stmt) {
// ALTER non nativamente idempotenti (es. mig.045 suppliers.stakeholder_type):
// pre-check su information_schema, salta se colonna/indice gia presenti.
if (shouldSkipAlter($pdo, $stmt)) {
continue;
}
try { try {
$pdo->exec($stmt); $pdo->exec($stmt);
$totalStmts++; $totalStmts++;
@@ -85,3 +138,21 @@ foreach (['skills','role_skills','user_skills','skill_course_map'] as $t) {
echo "[OK] $t pronta (" . count($c) . " colonne).\n"; echo "[OK] $t pronta (" . count($c) . " colonne).\n";
} }
echo "[OK] Competenze pronte (A4 Fase 4.2).\n"; echo "[OK] Competenze pronte (A4 Fase 4.2).\n";
// Verifica 4.3 (RACI + link m2m), 4.4 (scadenziario), 4.5 (stakeholder).
foreach (['raci_assignments','procedure_inventory','procedure_risk','inventory_risk','risk_measure','review_schedule'] as $t) {
$c = $pdo->query("SHOW COLUMNS FROM `$t`")->fetchAll(PDO::FETCH_COLUMN, 0);
if (!$c) {
fwrite(STDERR, "[ATTENZIONE] tabella mancante: $t\n");
exit(1);
}
echo "[OK] $t pronta (" . count($c) . " colonne).\n";
}
echo "[OK] RACI + scadenziario pronti (A4 Fasi 4.3 e 4.4).\n";
$supCols = $pdo->query("SHOW COLUMNS FROM suppliers")->fetchAll(PDO::FETCH_COLUMN, 0);
if (!in_array('stakeholder_type', $supCols, true)) {
fwrite(STDERR, "[ATTENZIONE] suppliers.stakeholder_type mancante (A4 Fase 4.5)\n");
exit(1);
}
echo "[OK] suppliers.stakeholder_type pronta (A4 Fase 4.5).\n";