[FEAT] Epic C / C5.2a — Attività stakeholder: questionari tipo + attività + calendario (mig.052)
Fondazione delle attività verso gli stakeholder (Simon C5 §4): - Questionari tipo (template): tipo questionario-da-compilare / firma-di-avvenuta-lettura, domande (JSON) + collegamento m2m a procedure (policies) E a misure/requisiti del framework (cfg_nis2_misure/requisiti). - Attività: basate (opz.) su un template, assegnabili a un CODICE stakeholder (tutti quelli di quel tipo) o a singoli stakeholder; pianificabili con data e scadenza. - Calendario NIS2: review_schedule esteso (ENUM entity_type += 'stakeholder_activity'); su create/update con scadenza la riga di calendario viene sincronizzata. - Invio: genera un magic-link per destinatario (token SHA-256) per il portale esterno (C5.2b). Email disattivate (kill-switch) -> i link si condividono manualmente. mig.052: stk_questionnaire_templates, stk_template_(procedures|misure|requisiti), stk_activities, stk_activity_targets, stk_activity_procedures + ALTER review_schedule. StakeholderActivityController + /api/stakeholder-activities/*; stakeholder-activities.html (2 tab: attività / questionari tipo + dettaglio invio); voce sidebar V2; help/i18n. Smoke prod OK (template m2m, attività by_code+calendario, assign, send magic-link, anti-IDOR individual, negativi 422; cleanup org 151 pulita). Additivo. v1.20.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c782aa54fa
commit
a5ff29e0da
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile — DDL idempotente Attività stakeholder (Epic C / C5.2a, mig.052).
|
||||||
|
* ----------------------------------------------------------------------------
|
||||||
|
* Crea (se mancanti) le tabelle del modulo Attività stakeholder ed estende
|
||||||
|
* l'ENUM entity_type di review_schedule con 'stakeholder_activity' (calendario NIS2).
|
||||||
|
* NESSUN dato di sistema: i questionari tipo sono per-org.
|
||||||
|
*
|
||||||
|
* Idempotente: CREATE TABLE IF NOT EXISTS + ALTER MODIFY (re-eseguibile senza
|
||||||
|
* errore). Allineato a docs/sql/052_stakeholder_activities.sql.
|
||||||
|
*
|
||||||
|
* Uso (dentro il container app):
|
||||||
|
* docker exec nis2-app php /var/www/nis2-agile/application/cli/seed_stakeholder_activities.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("Solo da CLI.\n"); }
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/env.php';
|
||||||
|
require_once __DIR__ . '/../config/database.php';
|
||||||
|
|
||||||
|
$pdo = Database::getInstance();
|
||||||
|
$pdo->exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||||
|
|
||||||
|
$ddl = [
|
||||||
|
"CREATE TABLE IF NOT EXISTS stk_questionnaire_templates (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT, organization_id INT NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL, kind ENUM('questionnaire','read_ack') NOT NULL DEFAULT 'questionnaire',
|
||||||
|
description TEXT NULL, content TEXT NULL, questions JSON NULL,
|
||||||
|
status ENUM('draft','active','archived') NOT NULL DEFAULT 'active',
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id), KEY idx_stkqt_org (organization_id),
|
||||||
|
CONSTRAINT fk_stkqt_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stkqt_user FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS stk_template_procedures (
|
||||||
|
template_id INT NOT NULL, policy_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (template_id, policy_id), KEY idx_stktp_policy (policy_id),
|
||||||
|
CONSTRAINT fk_stktp_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stktp_policy FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS stk_template_misure (
|
||||||
|
template_id INT NOT NULL, misura_code VARCHAR(16) NOT NULL,
|
||||||
|
PRIMARY KEY (template_id, misura_code), KEY idx_stktm_misura (misura_code),
|
||||||
|
CONSTRAINT fk_stktm_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stktm_misura FOREIGN KEY (misura_code) REFERENCES cfg_nis2_misure (misura_code) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS stk_template_requisiti (
|
||||||
|
template_id INT NOT NULL, requisito_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (template_id, requisito_id), KEY idx_stktr_req (requisito_id),
|
||||||
|
CONSTRAINT fk_stktr_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stktr_req FOREIGN KEY (requisito_id) REFERENCES cfg_nis2_requisiti (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS stk_activities (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT, organization_id INT NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL, type ENUM('questionnaire','read_ack','action') NOT NULL DEFAULT 'questionnaire',
|
||||||
|
template_id INT NULL, description TEXT NULL,
|
||||||
|
assign_mode ENUM('by_code','individual') NOT NULL DEFAULT 'individual', stak_code VARCHAR(16) NULL,
|
||||||
|
planned_date DATE NULL, due_date DATE NULL,
|
||||||
|
status ENUM('draft','scheduled','sent','in_progress','completed','cancelled') NOT NULL DEFAULT 'draft',
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id), KEY idx_stka_org (organization_id), KEY idx_stka_tpl (template_id),
|
||||||
|
CONSTRAINT fk_stka_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stka_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_stka_user FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS stk_activity_targets (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT, activity_id INT NOT NULL, stakeholder_id INT NOT NULL,
|
||||||
|
access_token_hash CHAR(64) NULL,
|
||||||
|
state ENUM('pending','sent','responded','acknowledged','expired') NOT NULL DEFAULT 'pending',
|
||||||
|
sent_at DATETIME NULL, responded_at DATETIME NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uq_stkat (activity_id, stakeholder_id),
|
||||||
|
KEY idx_stkat_stk (stakeholder_id), KEY idx_stkat_token (access_token_hash),
|
||||||
|
CONSTRAINT fk_stkat_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stkat_stk FOREIGN KEY (stakeholder_id) REFERENCES stakeholders (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS stk_activity_procedures (
|
||||||
|
activity_id INT NOT NULL, policy_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (activity_id, policy_id), KEY idx_stkap_policy (policy_id),
|
||||||
|
CONSTRAINT fk_stkap_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stkap_policy FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
];
|
||||||
|
foreach ($ddl as $stmt) {
|
||||||
|
try { $pdo->exec($stmt); }
|
||||||
|
catch (PDOException $e) { if (!in_array($e->errorInfo[1] ?? 0, [1050, 1061], true)) { throw $e; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estende l'ENUM del calendario (review_schedule). Idempotente in effetto.
|
||||||
|
try {
|
||||||
|
$pdo->exec("ALTER TABLE review_schedule
|
||||||
|
MODIFY COLUMN entity_type ENUM('role','skill','inventory','procedure','risk','supplier','measure','custom','stakeholder_activity') NOT NULL");
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
fwrite(STDERR, "WARN ALTER review_schedule: " . $e->getMessage() . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = [];
|
||||||
|
foreach (['stk_questionnaire_templates','stk_template_procedures','stk_template_misure','stk_template_requisiti',
|
||||||
|
'stk_activities','stk_activity_targets','stk_activity_procedures'] as $t) {
|
||||||
|
$counts[$t] = (int) $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn();
|
||||||
|
}
|
||||||
|
$enum = $pdo->query("SELECT COLUMN_TYPE FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'review_schedule' AND COLUMN_NAME = 'entity_type'")->fetchColumn();
|
||||||
|
echo "OK seed-stakeholder-activities — " . json_encode($counts, JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo "review_schedule.entity_type has stakeholder_activity: " . (str_contains((string) $enum, 'stakeholder_activity') ? 'YES' : 'NO') . "\n";
|
||||||
@@ -42,7 +42,7 @@ class ReviewScheduleController extends BaseController
|
|||||||
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
|
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
|
||||||
|
|
||||||
private const ENTITY_TYPES = [
|
private const ENTITY_TYPES = [
|
||||||
'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom',
|
'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom', 'stakeholder_activity',
|
||||||
];
|
];
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -0,0 +1,591 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Attività stakeholder (Epic C / C5.2a)
|
||||||
|
* ----------------------------------------------------------------------------
|
||||||
|
* Gestione interna delle ATTIVITA' verso gli stakeholder (Simon C5 §4):
|
||||||
|
* - QUESTIONARI TIPO (template): nome, tipo (questionario da compilare /
|
||||||
|
* firma di avvenuta lettura), domande (JSON) e collegamento m2m a procedure
|
||||||
|
* (policies) E a misure/requisiti del framework (cfg_nis2_misure/requisiti).
|
||||||
|
* - ATTIVITA': istanza basata (opz.) su un template, pianificabile con data e
|
||||||
|
* scadenza (→ calendario NIS2 review_schedule), assegnabile a un CODICE
|
||||||
|
* stakeholder (tutti quelli di quel tipo) o a singoli stakeholder selezionati.
|
||||||
|
* - INVIO: materializza i destinatari (target) e genera un magic-link per
|
||||||
|
* destinatario (token SHA-256) per il portale esterno self-service (C5.2b).
|
||||||
|
* Le email sono disattivate (kill-switch) → i link vanno condivisi a mano.
|
||||||
|
*
|
||||||
|
* La sotto-dashboard feedback (risposte/firma, commenti, allegati) e il portale
|
||||||
|
* esterno arrivano in C5.2b. Le AZIONI (§4.2) si appoggiano a NCR/CAPA (non qui).
|
||||||
|
*
|
||||||
|
* Multi-tenancy: ogni query filtra organization_id. Anti-IDOR su template/policy/
|
||||||
|
* misura/requisito/stakeholder (verificati org o catalogo di sistema).
|
||||||
|
* NOTE: Database::query/fetchAll/fetchOne/insert/update/delete; jsonSuccess/Error exit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
|
||||||
|
class StakeholderActivityController extends BaseController
|
||||||
|
{
|
||||||
|
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// QUESTIONARI TIPO (template)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** GET /api/stakeholder-activities/templates */
|
||||||
|
public function templates(): void
|
||||||
|
{
|
||||||
|
$this->requireOrgAccess();
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
$rows = Database::fetchAll(
|
||||||
|
'SELECT t.id, t.name, t.kind, t.description, t.status, t.questions, t.updated_at,
|
||||||
|
(SELECT COUNT(*) FROM stk_template_procedures p WHERE p.template_id = t.id) AS n_proc,
|
||||||
|
(SELECT COUNT(*) FROM stk_template_misure m WHERE m.template_id = t.id) AS n_mis,
|
||||||
|
(SELECT COUNT(*) FROM stk_template_requisiti r WHERE r.template_id = t.id) AS n_req
|
||||||
|
FROM stk_questionnaire_templates t
|
||||||
|
WHERE t.organization_id = ?
|
||||||
|
ORDER BY t.name ASC',
|
||||||
|
[$orgId]
|
||||||
|
);
|
||||||
|
$out = array_map(static function ($t) {
|
||||||
|
$q = $t['questions'] ? json_decode($t['questions'], true) : [];
|
||||||
|
return [
|
||||||
|
'id' => (int) $t['id'], 'name' => $t['name'], 'kind' => $t['kind'],
|
||||||
|
'description' => $t['description'], 'status' => $t['status'],
|
||||||
|
'n_questions' => is_array($q) ? count($q) : 0,
|
||||||
|
'n_proc' => (int) $t['n_proc'], 'n_mis' => (int) $t['n_mis'], 'n_req' => (int) $t['n_req'],
|
||||||
|
'updated_at' => $t['updated_at'],
|
||||||
|
];
|
||||||
|
}, $rows);
|
||||||
|
$this->jsonSuccess(['templates' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/stakeholder-activities/templates/{id} */
|
||||||
|
public function getTemplate(int $id): void
|
||||||
|
{
|
||||||
|
$this->requireOrgAccess();
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
$t = Database::fetchOne(
|
||||||
|
'SELECT id, name, kind, description, content, questions, status
|
||||||
|
FROM stk_questionnaire_templates WHERE id = ? AND organization_id = ?',
|
||||||
|
[$id, $orgId]
|
||||||
|
);
|
||||||
|
if (!$t) { $this->jsonError('Questionario tipo non trovato', 404, 'NOT_FOUND'); }
|
||||||
|
$q = $t['questions'] ? json_decode($t['questions'], true) : [];
|
||||||
|
$this->jsonSuccess([
|
||||||
|
'id' => (int) $t['id'], 'name' => $t['name'], 'kind' => $t['kind'],
|
||||||
|
'description' => $t['description'], 'content' => $t['content'],
|
||||||
|
'questions' => is_array($q) ? $q : [], 'status' => $t['status'],
|
||||||
|
'policy_ids' => $this->idCol('SELECT policy_id FROM stk_template_procedures WHERE template_id = ?', $id),
|
||||||
|
'misura_codes' => $this->valCol('SELECT misura_code FROM stk_template_misure WHERE template_id = ?', $id),
|
||||||
|
'requisito_ids'=> $this->idCol('SELECT requisito_id FROM stk_template_requisiti WHERE template_id = ?', $id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/stakeholder-activities/templates */
|
||||||
|
public function createTemplate(): void
|
||||||
|
{
|
||||||
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
$b = $this->getJsonBody();
|
||||||
|
|
||||||
|
$name = trim((string) ($b['name'] ?? ''));
|
||||||
|
if ($name === '' || mb_strlen($name) > 255) { $this->jsonError('Nome obbligatorio (max 255)', 422, 'INVALID_NAME'); }
|
||||||
|
$kind = ($b['kind'] ?? '') === 'read_ack' ? 'read_ack' : 'questionnaire';
|
||||||
|
$questions = $this->validateQuestions($b['questions'] ?? [], $kind);
|
||||||
|
$polIds = $this->validatePolicyIds($b['policy_ids'] ?? null, $orgId);
|
||||||
|
$misCodes = $this->validateMisuraCodes($b['misura_codes'] ?? null);
|
||||||
|
$reqIds = $this->validateRequisitoIds($b['requisito_ids'] ?? null);
|
||||||
|
|
||||||
|
$id = Database::insert('stk_questionnaire_templates', [
|
||||||
|
'organization_id' => $orgId,
|
||||||
|
'name' => $name,
|
||||||
|
'kind' => $kind,
|
||||||
|
'description' => $this->nullableStr($b['description'] ?? null),
|
||||||
|
'content' => $kind === 'read_ack' ? $this->nullableStr($b['content'] ?? null) : null,
|
||||||
|
'questions' => $kind === 'questionnaire' ? json_encode($questions, JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
'status' => in_array($b['status'] ?? '', ['draft','active','archived'], true) ? $b['status'] : 'active',
|
||||||
|
'created_by' => $this->getCurrentUserId(),
|
||||||
|
]);
|
||||||
|
$this->syncTemplateLinks((int) $id, $polIds, $misCodes, $reqIds);
|
||||||
|
$this->logAudit('stk_template_created', 'stk_questionnaire_template', (int) $id, ['name' => $name, 'kind' => $kind]);
|
||||||
|
$this->jsonSuccess(['id' => (int) $id], 'Questionario tipo creato', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/stakeholder-activities/templates/{id} */
|
||||||
|
public function updateTemplate(int $id): void
|
||||||
|
{
|
||||||
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
$b = $this->getJsonBody();
|
||||||
|
|
||||||
|
$t = Database::fetchOne('SELECT id, kind FROM stk_questionnaire_templates WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||||
|
if (!$t) { $this->jsonError('Questionario tipo non trovato', 404, 'NOT_FOUND'); }
|
||||||
|
$kind = $t['kind'];
|
||||||
|
if ($this->hasParam('kind')) { $kind = ($b['kind'] === 'read_ack') ? 'read_ack' : 'questionnaire'; }
|
||||||
|
|
||||||
|
$updates = [];
|
||||||
|
if ($this->hasParam('name')) {
|
||||||
|
$name = trim((string) ($b['name'] ?? ''));
|
||||||
|
if ($name === '' || mb_strlen($name) > 255) { $this->jsonError('Nome non valido (max 255)', 422, 'INVALID_NAME'); }
|
||||||
|
$updates['name'] = $name;
|
||||||
|
}
|
||||||
|
if ($this->hasParam('kind')) { $updates['kind'] = $kind; }
|
||||||
|
if ($this->hasParam('description')) { $updates['description'] = $this->nullableStr($b['description'] ?? null); }
|
||||||
|
if ($this->hasParam('content')) { $updates['content'] = $this->nullableStr($b['content'] ?? null); }
|
||||||
|
if ($this->hasParam('questions')) { $updates['questions'] = json_encode($this->validateQuestions($b['questions'] ?? [], $kind), JSON_UNESCAPED_UNICODE); }
|
||||||
|
if ($this->hasParam('status') && in_array($b['status'], ['draft','active','archived'], true)) { $updates['status'] = $b['status']; }
|
||||||
|
|
||||||
|
if (!empty($updates)) {
|
||||||
|
Database::update('stk_questionnaire_templates', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]);
|
||||||
|
}
|
||||||
|
if ($this->hasParam('policy_ids') || $this->hasParam('misura_codes') || $this->hasParam('requisito_ids')) {
|
||||||
|
$polIds = $this->validatePolicyIds($b['policy_ids'] ?? [], $orgId);
|
||||||
|
$misCodes = $this->validateMisuraCodes($b['misura_codes'] ?? []);
|
||||||
|
$reqIds = $this->validateRequisitoIds($b['requisito_ids'] ?? []);
|
||||||
|
$this->syncTemplateLinks($id, $polIds, $misCodes, $reqIds);
|
||||||
|
}
|
||||||
|
$this->logAudit('stk_template_updated', 'stk_questionnaire_template', $id, array_keys($updates));
|
||||||
|
$this->jsonSuccess(['id' => $id], 'Questionario tipo aggiornato');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /api/stakeholder-activities/templates/{id} */
|
||||||
|
public function deleteTemplate(int $id): void
|
||||||
|
{
|
||||||
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
// le attività che lo usavano restano (template_id -> NULL via FK SET NULL)
|
||||||
|
$del = Database::delete('stk_questionnaire_templates', 'id = ? AND organization_id = ?', [$id, $orgId]);
|
||||||
|
if ($del === 0) { $this->jsonError('Questionario tipo non trovato', 404, 'NOT_FOUND'); }
|
||||||
|
$this->logAudit('stk_template_deleted', 'stk_questionnaire_template', $id);
|
||||||
|
$this->jsonSuccess(null, 'Questionario tipo eliminato');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/stakeholder-activities/pickers
|
||||||
|
* Opzioni per i form dei template: procedure (org) + misure + requisiti (catalogo).
|
||||||
|
*/
|
||||||
|
public function pickers(): void
|
||||||
|
{
|
||||||
|
$this->requireOrgAccess();
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
$policies = Database::fetchAll(
|
||||||
|
'SELECT id, title FROM policies WHERE organization_id = ? AND deleted_at IS NULL ORDER BY title ASC',
|
||||||
|
[$orgId]
|
||||||
|
);
|
||||||
|
$misure = Database::fetchAll('SELECT misura_code, misura_descr FROM cfg_nis2_misure ORDER BY ord ASC');
|
||||||
|
$requisiti = Database::fetchAll(
|
||||||
|
'SELECT id, requisito_code, misura_code FROM cfg_nis2_requisiti ORDER BY n ASC, id ASC'
|
||||||
|
);
|
||||||
|
$this->jsonSuccess([
|
||||||
|
'policies' => array_map(static fn($p) => ['id' => (int) $p['id'], 'title' => $p['title']], $policies),
|
||||||
|
'misure' => array_map(static fn($m) => ['code' => $m['misura_code'], 'descr' => $m['misura_descr']], $misure),
|
||||||
|
'requisiti' => array_map(static fn($r) => ['id' => (int) $r['id'], 'code' => $r['requisito_code'], 'misura_code' => $r['misura_code']], $requisiti),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// ATTIVITA'
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** GET /api/stakeholder-activities/list */
|
||||||
|
public function list(): void
|
||||||
|
{
|
||||||
|
$this->requireOrgAccess();
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
$rows = Database::fetchAll(
|
||||||
|
'SELECT a.id, a.title, a.type, a.template_id, t.name AS template_name, a.description,
|
||||||
|
a.assign_mode, a.stak_code, a.planned_date, a.due_date, a.status, a.updated_at,
|
||||||
|
(SELECT COUNT(*) FROM stk_activity_targets g WHERE g.activity_id = a.id) AS n_targets,
|
||||||
|
(SELECT COUNT(*) FROM stk_activity_targets g WHERE g.activity_id = a.id AND g.state IN (\'responded\',\'acknowledged\')) AS n_done
|
||||||
|
FROM stk_activities a
|
||||||
|
LEFT JOIN stk_questionnaire_templates t ON t.id = a.template_id
|
||||||
|
WHERE a.organization_id = ?
|
||||||
|
ORDER BY (a.due_date IS NULL), a.due_date ASC, a.id DESC',
|
||||||
|
[$orgId]
|
||||||
|
);
|
||||||
|
$out = array_map(static fn($a) => [
|
||||||
|
'id' => (int) $a['id'], 'title' => $a['title'], 'type' => $a['type'],
|
||||||
|
'template_id' => $a['template_id'] !== null ? (int) $a['template_id'] : null,
|
||||||
|
'template_name' => $a['template_name'], 'description' => $a['description'],
|
||||||
|
'assign_mode' => $a['assign_mode'], 'stak_code' => $a['stak_code'],
|
||||||
|
'planned_date' => $a['planned_date'], 'due_date' => $a['due_date'], 'status' => $a['status'],
|
||||||
|
'n_targets' => (int) $a['n_targets'], 'n_done' => (int) $a['n_done'], 'updated_at' => $a['updated_at'],
|
||||||
|
], $rows);
|
||||||
|
$this->jsonSuccess(['activities' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/stakeholder-activities/{id} */
|
||||||
|
public function get(int $id): void
|
||||||
|
{
|
||||||
|
$this->requireOrgAccess();
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
$a = Database::fetchOne('SELECT * FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||||
|
if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); }
|
||||||
|
$targets = Database::fetchAll(
|
||||||
|
'SELECT g.id, g.stakeholder_id, s.name AS stakeholder_name, s.stak_code, g.state, g.sent_at, g.responded_at,
|
||||||
|
(g.access_token_hash IS NOT NULL) AS has_token
|
||||||
|
FROM stk_activity_targets g JOIN stakeholders s ON s.id = g.stakeholder_id
|
||||||
|
WHERE g.activity_id = ? ORDER BY s.name ASC',
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
$this->jsonSuccess([
|
||||||
|
'id' => (int) $a['id'], 'title' => $a['title'], 'type' => $a['type'],
|
||||||
|
'template_id' => $a['template_id'] !== null ? (int) $a['template_id'] : null,
|
||||||
|
'description' => $a['description'], 'assign_mode' => $a['assign_mode'], 'stak_code' => $a['stak_code'],
|
||||||
|
'planned_date' => $a['planned_date'], 'due_date' => $a['due_date'], 'status' => $a['status'],
|
||||||
|
'policy_ids' => $this->idCol('SELECT policy_id FROM stk_activity_procedures WHERE activity_id = ?', $id),
|
||||||
|
'targets' => array_map(static fn($g) => [
|
||||||
|
'id' => (int) $g['id'], 'stakeholder_id' => (int) $g['stakeholder_id'],
|
||||||
|
'stakeholder_name' => $g['stakeholder_name'], 'stak_code' => $g['stak_code'],
|
||||||
|
'state' => $g['state'], 'sent_at' => $g['sent_at'], 'responded_at' => $g['responded_at'],
|
||||||
|
'has_token' => ((int) $g['has_token'] === 1),
|
||||||
|
], $targets),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/stakeholder-activities/create */
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
$b = $this->getJsonBody();
|
||||||
|
|
||||||
|
$title = trim((string) ($b['title'] ?? ''));
|
||||||
|
if ($title === '' || mb_strlen($title) > 255) { $this->jsonError('Titolo obbligatorio (max 255)', 422, 'INVALID_TITLE'); }
|
||||||
|
$type = in_array($b['type'] ?? '', ['questionnaire','read_ack','action'], true) ? $b['type'] : 'questionnaire';
|
||||||
|
$templateId = $this->validateTemplate($b['template_id'] ?? null, $orgId);
|
||||||
|
[$assignMode, $stakCode] = $this->validateAssign($b, $orgId);
|
||||||
|
$planned = $this->validateDate($b['planned_date'] ?? null, 'planned_date');
|
||||||
|
$due = $this->validateDate($b['due_date'] ?? null, 'due_date');
|
||||||
|
$polIds = $this->validatePolicyIds($b['policy_ids'] ?? null, $orgId);
|
||||||
|
|
||||||
|
$id = Database::insert('stk_activities', [
|
||||||
|
'organization_id' => $orgId,
|
||||||
|
'title' => $title,
|
||||||
|
'type' => $type,
|
||||||
|
'template_id' => $templateId,
|
||||||
|
'description' => $this->nullableStr($b['description'] ?? null),
|
||||||
|
'assign_mode' => $assignMode,
|
||||||
|
'stak_code' => $assignMode === 'by_code' ? $stakCode : null,
|
||||||
|
'planned_date' => $planned,
|
||||||
|
'due_date' => $due,
|
||||||
|
'status' => $due ? 'scheduled' : 'draft',
|
||||||
|
'created_by' => $this->getCurrentUserId(),
|
||||||
|
]);
|
||||||
|
$this->syncActivityProcedures((int) $id, $polIds);
|
||||||
|
$this->upsertCalendar((int) $id, $orgId, $title, $due);
|
||||||
|
$this->logAudit('stk_activity_created', 'stk_activity', (int) $id, ['title' => $title, 'type' => $type]);
|
||||||
|
$this->jsonSuccess(['id' => (int) $id], 'Attività creata', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/stakeholder-activities/{id} */
|
||||||
|
public function update(int $id): void
|
||||||
|
{
|
||||||
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
$b = $this->getJsonBody();
|
||||||
|
|
||||||
|
$a = Database::fetchOne('SELECT id, title, due_date FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||||
|
if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); }
|
||||||
|
|
||||||
|
$updates = [];
|
||||||
|
if ($this->hasParam('title')) {
|
||||||
|
$title = trim((string) ($b['title'] ?? ''));
|
||||||
|
if ($title === '' || mb_strlen($title) > 255) { $this->jsonError('Titolo non valido (max 255)', 422, 'INVALID_TITLE'); }
|
||||||
|
$updates['title'] = $title;
|
||||||
|
}
|
||||||
|
if ($this->hasParam('type') && in_array($b['type'], ['questionnaire','read_ack','action'], true)) { $updates['type'] = $b['type']; }
|
||||||
|
if ($this->hasParam('template_id')) { $updates['template_id'] = $this->validateTemplate($b['template_id'] ?? null, $orgId); }
|
||||||
|
if ($this->hasParam('description')) { $updates['description'] = $this->nullableStr($b['description'] ?? null); }
|
||||||
|
if ($this->hasParam('assign_mode') || $this->hasParam('stak_code')) {
|
||||||
|
[$assignMode, $stakCode] = $this->validateAssign($b, $orgId);
|
||||||
|
$updates['assign_mode'] = $assignMode;
|
||||||
|
$updates['stak_code'] = $assignMode === 'by_code' ? $stakCode : null;
|
||||||
|
}
|
||||||
|
if ($this->hasParam('planned_date')) { $updates['planned_date'] = $this->validateDate($b['planned_date'] ?? null, 'planned_date'); }
|
||||||
|
if ($this->hasParam('due_date')) { $updates['due_date'] = $this->validateDate($b['due_date'] ?? null, 'due_date'); }
|
||||||
|
if ($this->hasParam('status') && in_array($b['status'], ['draft','scheduled','sent','in_progress','completed','cancelled'], true)) { $updates['status'] = $b['status']; }
|
||||||
|
|
||||||
|
if (!empty($updates)) {
|
||||||
|
Database::update('stk_activities', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]);
|
||||||
|
}
|
||||||
|
if ($this->hasParam('policy_ids')) {
|
||||||
|
$this->syncActivityProcedures($id, $this->validatePolicyIds($b['policy_ids'] ?? [], $orgId));
|
||||||
|
}
|
||||||
|
// riallinea il calendario
|
||||||
|
$title = $updates['title'] ?? $a['title'];
|
||||||
|
$due = array_key_exists('due_date', $updates) ? $updates['due_date'] : $a['due_date'];
|
||||||
|
$this->upsertCalendar($id, $orgId, $title, $due);
|
||||||
|
|
||||||
|
$this->logAudit('stk_activity_updated', 'stk_activity', $id, array_keys($updates));
|
||||||
|
$this->jsonSuccess(['id' => $id, 'updated' => array_keys($updates)], 'Attività aggiornata');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /api/stakeholder-activities/{id} */
|
||||||
|
public function delete(int $id): void
|
||||||
|
{
|
||||||
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
$a = Database::fetchOne('SELECT id FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||||
|
if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); }
|
||||||
|
Database::delete('review_schedule', 'organization_id = ? AND entity_type = ? AND entity_id = ?', [$orgId, 'stakeholder_activity', $id]);
|
||||||
|
Database::delete('stk_activities', 'id = ? AND organization_id = ?', [$id, $orgId]); // targets/procedure cascata
|
||||||
|
$this->logAudit('stk_activity_deleted', 'stk_activity', $id);
|
||||||
|
$this->jsonSuccess(null, 'Attività eliminata');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stakeholder-activities/{id}/assign
|
||||||
|
* Materializza i destinatari. by_code → tutti gli stakeholder con quel codice;
|
||||||
|
* individual → body {stakeholder_ids:[...]} (validati org). Idempotente (UNIQUE).
|
||||||
|
*/
|
||||||
|
public function assign(int $id): void
|
||||||
|
{
|
||||||
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
$a = Database::fetchOne('SELECT id, assign_mode, stak_code FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||||
|
if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); }
|
||||||
|
$b = $this->getJsonBody();
|
||||||
|
|
||||||
|
$stakeholderIds = [];
|
||||||
|
if ($a['assign_mode'] === 'by_code') {
|
||||||
|
if (!$a['stak_code']) { $this->jsonError('Attività by_code senza codice stakeholder', 422, 'NO_CODE'); }
|
||||||
|
$stakeholderIds = $this->valCol(
|
||||||
|
'SELECT id FROM stakeholders WHERE organization_id = ? AND stak_code = ?',
|
||||||
|
$orgId, [$a['stak_code']]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$raw = $b['stakeholder_ids'] ?? null;
|
||||||
|
if (!is_array($raw) || !$raw) { $this->jsonError('Seleziona almeno uno stakeholder', 422, 'NO_TARGETS'); }
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map('intval', $raw), static fn($i) => $i > 0)));
|
||||||
|
$place = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$stakeholderIds = $this->valCol(
|
||||||
|
"SELECT id FROM stakeholders WHERE organization_id = ? AND id IN ($place)",
|
||||||
|
$orgId, $ids
|
||||||
|
);
|
||||||
|
if (count($stakeholderIds) !== count($ids)) { $this->jsonError('Uno o più stakeholder non sono validi', 422, 'INVALID_TARGETS'); }
|
||||||
|
}
|
||||||
|
if (!$stakeholderIds) { $this->jsonError('Nessuno stakeholder da assegnare per questo criterio', 422, 'EMPTY_TARGETS'); }
|
||||||
|
|
||||||
|
$added = 0;
|
||||||
|
foreach ($stakeholderIds as $sid) {
|
||||||
|
try {
|
||||||
|
Database::insert('stk_activity_targets', ['activity_id' => $id, 'stakeholder_id' => (int) $sid, 'state' => 'pending']);
|
||||||
|
$added++;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if (($e->errorInfo[1] ?? 0) !== 1062) { throw $e; } // 1062 = già assegnato (idempotente)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$total = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM stk_activity_targets WHERE activity_id = ?', [$id])['c'];
|
||||||
|
$this->logAudit('stk_activity_assigned', 'stk_activity', $id, ['added' => $added, 'total' => $total]);
|
||||||
|
$this->jsonSuccess(['added' => $added, 'total_targets' => $total], 'Destinatari assegnati');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stakeholder-activities/{id}/send
|
||||||
|
* Genera il magic-link per ogni destinatario (token SHA-256), stato='sent'.
|
||||||
|
* Email DISATTIVATE (kill-switch) → i link vengono restituiti per la condivisione manuale.
|
||||||
|
*/
|
||||||
|
public function send(int $id): void
|
||||||
|
{
|
||||||
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||||
|
$orgId = $this->getCurrentOrgId();
|
||||||
|
$a = Database::fetchOne('SELECT id, due_date, title FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||||
|
if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); }
|
||||||
|
|
||||||
|
$targets = Database::fetchAll(
|
||||||
|
'SELECT g.id, g.stakeholder_id, s.name AS stakeholder_name, s.contact_email, g.access_token_hash
|
||||||
|
FROM stk_activity_targets g JOIN stakeholders s ON s.id = g.stakeholder_id
|
||||||
|
WHERE g.activity_id = ?',
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
if (!$targets) { $this->jsonError('Nessun destinatario: assegna prima gli stakeholder', 422, 'NO_TARGETS'); }
|
||||||
|
|
||||||
|
$links = [];
|
||||||
|
foreach ($targets as $g) {
|
||||||
|
$token = bin2hex(random_bytes(24)); // 48 hex
|
||||||
|
Database::update('stk_activity_targets', [
|
||||||
|
'access_token_hash' => hash('sha256', $token),
|
||||||
|
'state' => 'sent',
|
||||||
|
'sent_at' => date('Y-m-d H:i:s'),
|
||||||
|
], 'id = ?', [(int) $g['id']]);
|
||||||
|
$links[] = [
|
||||||
|
'target_id' => (int) $g['id'],
|
||||||
|
'stakeholder_name' => $g['stakeholder_name'],
|
||||||
|
'contact_email' => $g['contact_email'],
|
||||||
|
'magic_link' => '/stk-portal.html?t=' . $token,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
Database::update('stk_activities', ['status' => 'sent'], 'id = ? AND organization_id = ?', [$id, $orgId]);
|
||||||
|
|
||||||
|
$emailOn = defined('EMAIL_SENDING_ENABLED') && EMAIL_SENDING_ENABLED;
|
||||||
|
$this->logAudit('stk_activity_sent', 'stk_activity', $id, ['targets' => count($links), 'email_enabled' => $emailOn]);
|
||||||
|
$this->jsonSuccess([
|
||||||
|
'sent' => count($links),
|
||||||
|
'email_sent' => false,
|
||||||
|
'links' => $links,
|
||||||
|
'note' => $emailOn
|
||||||
|
? 'Invio email non ancora collegato: condividi i magic-link con gli stakeholder.'
|
||||||
|
: 'Email disattivate (kill-switch): copia e condividi manualmente i magic-link qui sotto.',
|
||||||
|
], 'Attività inviata (magic-link generati)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// HELPER
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function validateTemplate($id, int $orgId): ?int
|
||||||
|
{
|
||||||
|
$id = ($id === null || $id === '') ? null : (int) $id;
|
||||||
|
if ($id === null) { return null; }
|
||||||
|
$row = Database::fetchOne('SELECT id FROM stk_questionnaire_templates WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||||
|
if (!$row) { $this->jsonError('Questionario tipo non valido', 422, 'INVALID_TEMPLATE'); }
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ritorna [assign_mode, stak_code]. by_code richiede uno stak_code visibile all'org. */
|
||||||
|
private function validateAssign(array $b, int $orgId): array
|
||||||
|
{
|
||||||
|
$mode = ($b['assign_mode'] ?? '') === 'by_code' ? 'by_code' : 'individual';
|
||||||
|
$code = null;
|
||||||
|
if ($mode === 'by_code') {
|
||||||
|
$code = trim((string) ($b['stak_code'] ?? ''));
|
||||||
|
if ($code === '') { $this->jsonError('Indica il codice stakeholder per l\'assegnazione per codice', 422, 'MISSING_CODE'); }
|
||||||
|
$ok = Database::fetchOne(
|
||||||
|
'SELECT code FROM cfg_stakeholder_types WHERE code = ? AND (organization_id IS NULL OR organization_id = ?)',
|
||||||
|
[$code, $orgId]
|
||||||
|
);
|
||||||
|
if (!$ok) { $this->jsonError('Codice stakeholder non valido', 422, 'INVALID_CODE'); }
|
||||||
|
}
|
||||||
|
return [$mode, $code];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateQuestions($raw, string $kind): array
|
||||||
|
{
|
||||||
|
if ($kind === 'read_ack') { return []; }
|
||||||
|
if ($raw === null || $raw === '') { return []; }
|
||||||
|
if (!is_array($raw)) { $this->jsonError('Le domande devono essere un array', 422, 'INVALID_QUESTIONS'); }
|
||||||
|
$allowed = ['text','yes_no','single_choice','multi_choice','scale_1_5','number'];
|
||||||
|
$out = [];
|
||||||
|
$i = 0;
|
||||||
|
foreach ($raw as $q) {
|
||||||
|
$i++;
|
||||||
|
if (!is_array($q)) { continue; }
|
||||||
|
$text = trim((string) ($q['text'] ?? ''));
|
||||||
|
if ($text === '') { continue; }
|
||||||
|
$type = in_array($q['type'] ?? '', $allowed, true) ? $q['type'] : 'text';
|
||||||
|
$item = [
|
||||||
|
'code' => trim((string) ($q['code'] ?? ('Q' . $i))),
|
||||||
|
'text' => mb_substr($text, 0, 500),
|
||||||
|
'type' => $type,
|
||||||
|
'required' => !empty($q['required']),
|
||||||
|
];
|
||||||
|
if (in_array($type, ['single_choice','multi_choice'], true) && !empty($q['options']) && is_array($q['options'])) {
|
||||||
|
$item['options'] = array_values(array_map(static fn($o) => mb_substr(trim((string) $o), 0, 200), $q['options']));
|
||||||
|
}
|
||||||
|
$out[] = $item;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validatePolicyIds($raw, int $orgId): array
|
||||||
|
{
|
||||||
|
if ($raw === null) { return []; }
|
||||||
|
if (!is_array($raw)) { $this->jsonError('policy_ids deve essere un array', 422, 'INVALID_POLICIES'); }
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map('intval', $raw), static fn($i) => $i > 0)));
|
||||||
|
if (!$ids) { return []; }
|
||||||
|
$place = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$rows = Database::fetchAll(
|
||||||
|
"SELECT id FROM policies WHERE id IN ($place) AND organization_id = ? AND deleted_at IS NULL",
|
||||||
|
array_merge($ids, [$orgId])
|
||||||
|
);
|
||||||
|
if (count($rows) !== count($ids)) { $this->jsonError('Una o più procedure non sono valide', 422, 'INVALID_POLICIES'); }
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateMisuraCodes($raw): array
|
||||||
|
{
|
||||||
|
if ($raw === null) { return []; }
|
||||||
|
if (!is_array($raw)) { $this->jsonError('misura_codes deve essere un array', 422, 'INVALID_MISURE'); }
|
||||||
|
$codes = array_values(array_unique(array_filter(array_map(static fn($c) => trim((string) $c), $raw), static fn($c) => $c !== '')));
|
||||||
|
if (!$codes) { return []; }
|
||||||
|
$place = implode(',', array_fill(0, count($codes), '?'));
|
||||||
|
$rows = Database::fetchAll("SELECT misura_code FROM cfg_nis2_misure WHERE misura_code IN ($place)", $codes);
|
||||||
|
if (count($rows) !== count($codes)) { $this->jsonError('Una o più misure non sono valide', 422, 'INVALID_MISURE'); }
|
||||||
|
return $codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateRequisitoIds($raw): array
|
||||||
|
{
|
||||||
|
if ($raw === null) { return []; }
|
||||||
|
if (!is_array($raw)) { $this->jsonError('requisito_ids deve essere un array', 422, 'INVALID_REQUISITI'); }
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map('intval', $raw), static fn($i) => $i > 0)));
|
||||||
|
if (!$ids) { return []; }
|
||||||
|
$place = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$rows = Database::fetchAll("SELECT id FROM cfg_nis2_requisiti WHERE id IN ($place)", $ids);
|
||||||
|
if (count($rows) !== count($ids)) { $this->jsonError('Uno o più requisiti non sono validi', 422, 'INVALID_REQUISITI'); }
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncTemplateLinks(int $tplId, array $polIds, array $misCodes, array $reqIds): void
|
||||||
|
{
|
||||||
|
Database::delete('stk_template_procedures', 'template_id = ?', [$tplId]);
|
||||||
|
foreach ($polIds as $p) { Database::insert('stk_template_procedures', ['template_id' => $tplId, 'policy_id' => (int) $p]); }
|
||||||
|
Database::delete('stk_template_misure', 'template_id = ?', [$tplId]);
|
||||||
|
foreach ($misCodes as $c) { Database::insert('stk_template_misure', ['template_id' => $tplId, 'misura_code' => $c]); }
|
||||||
|
Database::delete('stk_template_requisiti', 'template_id = ?', [$tplId]);
|
||||||
|
foreach ($reqIds as $r) { Database::insert('stk_template_requisiti', ['template_id' => $tplId, 'requisito_id' => (int) $r]); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncActivityProcedures(int $activityId, array $polIds): void
|
||||||
|
{
|
||||||
|
Database::delete('stk_activity_procedures', 'activity_id = ?', [$activityId]);
|
||||||
|
foreach ($polIds as $p) { Database::insert('stk_activity_procedures', ['activity_id' => $activityId, 'policy_id' => (int) $p]); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crea/aggiorna/elimina la riga di calendario (review_schedule) per l'attività. */
|
||||||
|
private function upsertCalendar(int $activityId, int $orgId, string $title, ?string $dueDate): void
|
||||||
|
{
|
||||||
|
if ($dueDate === null) {
|
||||||
|
Database::delete('review_schedule', 'organization_id = ? AND entity_type = ? AND entity_id = ?', [$orgId, 'stakeholder_activity', $activityId]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Database::query(
|
||||||
|
'INSERT INTO review_schedule (organization_id, entity_type, entity_id, title, next_review_date, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE title = VALUES(title), next_review_date = VALUES(next_review_date)',
|
||||||
|
[$orgId, 'stakeholder_activity', $activityId, mb_substr('Attività stakeholder: ' . $title, 0, 255), $dueDate, $this->getCurrentUserId()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateDate($v, string $field): ?string
|
||||||
|
{
|
||||||
|
if ($v === null || $v === '') { return null; }
|
||||||
|
$d = trim((string) $v);
|
||||||
|
$dt = DateTime::createFromFormat('Y-m-d', $d);
|
||||||
|
if (!$dt || $dt->format('Y-m-d') !== $d) { $this->jsonError("Data $field non valida (atteso AAAA-MM-GG)", 422, 'INVALID_DATE'); }
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableStr($v, ?int $max = null): ?string
|
||||||
|
{
|
||||||
|
if ($v === null) { return null; }
|
||||||
|
$s = trim((string) $v);
|
||||||
|
if ($s === '') { return null; }
|
||||||
|
if ($max !== null && mb_strlen($s) > $max) { $s = mb_substr($s, 0, $max); }
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colonna di interi da una query con un solo parametro id. */
|
||||||
|
private function idCol(string $sql, int $param): array
|
||||||
|
{
|
||||||
|
return array_map(static fn($r) => (int) array_values($r)[0], Database::fetchAll($sql, [$param]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colonna di valori: SELECT col FROM ... WHERE org = ? [AND ... IN (...)]. */
|
||||||
|
private function valCol(string $sql, int $orgId, array $extra = []): array
|
||||||
|
{
|
||||||
|
$rows = Database::fetchAll($sql, array_merge([$orgId], $extra));
|
||||||
|
return array_map(static fn($r) => array_values($r)[0], $rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 052 — Attività stakeholder: questionari tipo + attività + calendario (Epic C / C5.2a)
|
||||||
|
-- =====================================================================
|
||||||
|
-- Simon C5 §4: a ogni stakeholder si possono assegnare ATTIVITA' (questionari
|
||||||
|
-- da compilare, richieste di firma di avvenuta lettura, o azioni), pianificabili
|
||||||
|
-- su un calendario NIS2. Questa migrazione (C5.2a) crea la FONDAZIONE:
|
||||||
|
-- - questionari tipo (template) con domande + m2m a procedure e misure/requisiti
|
||||||
|
-- - attività + assegnazione (per codice stakeholder o per singoli)
|
||||||
|
-- - destinatari (target) con token magic-link per il portale esterno (C5.2b)
|
||||||
|
-- La sotto-dashboard feedback (risposte/firma, commenti, allegati) e il portale
|
||||||
|
-- esterno arrivano in C5.2b (mig.053).
|
||||||
|
--
|
||||||
|
-- Calendario: si RIUSA review_schedule (mig.044) aggiungendo il valore
|
||||||
|
-- 'stakeholder_activity' all'ENUM entity_type (ALTER MODIFY, idempotente in
|
||||||
|
-- effetto: re-eseguibile senza errore).
|
||||||
|
--
|
||||||
|
-- Ancoraggio: GV.SC-02 (ruoli/responsabilità verso terze parti), GV.SC-05/07
|
||||||
|
-- (requisiti negli accordi, monitoraggio nel tempo) -> art. 24 D.Lgs. 138/2024.
|
||||||
|
--
|
||||||
|
-- Additivo, reversibile. Runner-safe: CREATE TABLE IF NOT EXISTS con FK dentro
|
||||||
|
-- il CREATE; nessun DELIMITER/stored-procedure; nessun ';' nei commenti.
|
||||||
|
-- Dati: nessun seed di sistema (i template sono per-org). DDL eseguita dal CLI
|
||||||
|
-- idempotente application/cli/seed_stakeholder_activities.php.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- 1) Questionari tipo (template)
|
||||||
|
CREATE TABLE IF NOT EXISTS stk_questionnaire_templates (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
organization_id INT NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
kind ENUM('questionnaire','read_ack') NOT NULL DEFAULT 'questionnaire',
|
||||||
|
description TEXT NULL,
|
||||||
|
content TEXT NULL, -- testo da leggere (per kind=read_ack)
|
||||||
|
questions JSON NULL, -- per kind=questionnaire: [{code,text,type,options,required}]
|
||||||
|
status ENUM('draft','active','archived') NOT NULL DEFAULT 'active',
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_stkqt_org (organization_id),
|
||||||
|
CONSTRAINT fk_stkqt_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stkqt_user FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 2) m2m template -> procedure (policies)
|
||||||
|
CREATE TABLE IF NOT EXISTS stk_template_procedures (
|
||||||
|
template_id INT NOT NULL,
|
||||||
|
policy_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (template_id, policy_id),
|
||||||
|
KEY idx_stktp_policy (policy_id),
|
||||||
|
CONSTRAINT fk_stktp_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stktp_policy FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 3) m2m template -> misure (cfg_nis2_misure.misura_code)
|
||||||
|
CREATE TABLE IF NOT EXISTS stk_template_misure (
|
||||||
|
template_id INT NOT NULL,
|
||||||
|
misura_code VARCHAR(16) NOT NULL,
|
||||||
|
PRIMARY KEY (template_id, misura_code),
|
||||||
|
KEY idx_stktm_misura (misura_code),
|
||||||
|
CONSTRAINT fk_stktm_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stktm_misura FOREIGN KEY (misura_code) REFERENCES cfg_nis2_misure (misura_code) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 4) m2m template -> requisiti (cfg_nis2_requisiti.id)
|
||||||
|
CREATE TABLE IF NOT EXISTS stk_template_requisiti (
|
||||||
|
template_id INT NOT NULL,
|
||||||
|
requisito_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (template_id, requisito_id),
|
||||||
|
KEY idx_stktr_req (requisito_id),
|
||||||
|
CONSTRAINT fk_stktr_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stktr_req FOREIGN KEY (requisito_id) REFERENCES cfg_nis2_requisiti (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 5) Attività
|
||||||
|
CREATE TABLE IF NOT EXISTS stk_activities (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
organization_id INT NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
type ENUM('questionnaire','read_ack','action') NOT NULL DEFAULT 'questionnaire',
|
||||||
|
template_id INT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
assign_mode ENUM('by_code','individual') NOT NULL DEFAULT 'individual',
|
||||||
|
stak_code VARCHAR(16) NULL, -- quando assign_mode=by_code
|
||||||
|
planned_date DATE NULL,
|
||||||
|
due_date DATE NULL,
|
||||||
|
status ENUM('draft','scheduled','sent','in_progress','completed','cancelled') NOT NULL DEFAULT 'draft',
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_stka_org (organization_id),
|
||||||
|
KEY idx_stka_tpl (template_id),
|
||||||
|
CONSTRAINT fk_stka_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stka_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_stka_user FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 6) Destinatari dell'attività (target) — un record per stakeholder coinvolto
|
||||||
|
CREATE TABLE IF NOT EXISTS stk_activity_targets (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
activity_id INT NOT NULL,
|
||||||
|
stakeholder_id INT NOT NULL,
|
||||||
|
access_token_hash CHAR(64) NULL, -- SHA-256 del magic-link (portale esterno C5.2b)
|
||||||
|
state ENUM('pending','sent','responded','acknowledged','expired') NOT NULL DEFAULT 'pending',
|
||||||
|
sent_at DATETIME NULL,
|
||||||
|
responded_at DATETIME NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_stkat (activity_id, stakeholder_id),
|
||||||
|
KEY idx_stkat_stk (stakeholder_id),
|
||||||
|
KEY idx_stkat_token (access_token_hash),
|
||||||
|
CONSTRAINT fk_stkat_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stkat_stk FOREIGN KEY (stakeholder_id) REFERENCES stakeholders (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 7) m2m attività -> procedure (policies) — collegamento all'elenco procedure (§4.1)
|
||||||
|
CREATE TABLE IF NOT EXISTS stk_activity_procedures (
|
||||||
|
activity_id INT NOT NULL,
|
||||||
|
policy_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (activity_id, policy_id),
|
||||||
|
KEY idx_stkap_policy (policy_id),
|
||||||
|
CONSTRAINT fk_stkap_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_stkap_policy FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 8) Calendario NIS2: estende l'ENUM di review_schedule (mig.044).
|
||||||
|
-- ALTER MODIFY e' idempotente in effetto (re-eseguibile senza errore).
|
||||||
|
ALTER TABLE review_schedule
|
||||||
|
MODIFY COLUMN entity_type ENUM('role','skill','inventory','procedure','risk','supplier','measure','custom','stakeholder_activity') NOT NULL;
|
||||||
|
|
||||||
|
-- ROLLBACK (manuale):
|
||||||
|
-- DROP TABLE IF EXISTS stk_activity_procedures
|
||||||
|
-- DROP TABLE IF EXISTS stk_activity_targets
|
||||||
|
-- DROP TABLE IF EXISTS stk_activities
|
||||||
|
-- DROP TABLE IF EXISTS stk_template_requisiti
|
||||||
|
-- DROP TABLE IF EXISTS stk_template_misure
|
||||||
|
-- DROP TABLE IF EXISTS stk_template_procedures
|
||||||
|
-- DROP TABLE IF EXISTS stk_questionnaire_templates
|
||||||
|
-- (review_schedule: rimettere l'ENUM senza 'stakeholder_activity' dopo aver eliminato le righe relative)
|
||||||
@@ -70,9 +70,9 @@
|
|||||||
</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=20260626"></script>
|
<script src="js/api.js?v=20260627"></script>
|
||||||
<script src="js/common.js?v=20260626"></script>
|
<script src="js/common.js?v=20260627"></script>
|
||||||
<script src="js/common-bi.js?v=20260626"></script>
|
<script src="js/common-bi.js?v=20260627"></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)
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|||||||
@@ -1088,10 +1088,10 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="js/api.js?v=20260626"></script>
|
<script src="js/api.js?v=20260627"></script>
|
||||||
<script src="js/common.js?v=20260626"></script>
|
<script src="js/common.js?v=20260627"></script>
|
||||||
<script src="js/i18n.js?v=20260626"></script>
|
<script src="js/i18n.js?v=20260627"></script>
|
||||||
<script src="js/help.js?v=20260626"></script>
|
<script src="js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
loadSidebar();
|
loadSidebar();
|
||||||
|
|||||||
@@ -154,8 +154,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -165,9 +165,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Auth check ───────────────────────────────────────────
|
// ── Auth check ───────────────────────────────────────────
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|||||||
+5
-5
@@ -361,8 +361,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -372,9 +372,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Auth & Init ─────────────────────────────────────────
|
// ── Auth & Init ─────────────────────────────────────────
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|||||||
@@ -349,14 +349,14 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="js/api.js?v=20260626"></script>
|
<script src="js/api.js?v=20260627"></script>
|
||||||
<script src="js/common.js?v=20260626"></script>
|
<script src="js/common.js?v=20260627"></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=20260626"></script>
|
<script src="js/common-bi.js?v=20260627"></script>
|
||||||
<script src="js/i18n.js?v=20260626"></script>
|
<script src="js/i18n.js?v=20260627"></script>
|
||||||
<script src="js/help.js?v=20260626"></script>
|
<script src="js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
|||||||
@@ -183,12 +183,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script src="/js/competenze.js?v=20260617"></script>
|
<script src="/js/competenze.js?v=20260617"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -382,8 +382,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -393,8 +393,8 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></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>`; }
|
||||||
|
|||||||
@@ -143,8 +143,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -154,9 +154,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Auth check ───────────────────────────────────────────
|
// ── Auth check ───────────────────────────────────────────
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|||||||
@@ -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=20260626"></script>
|
<script src="js/api.js?v=20260627"></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');
|
||||||
|
|||||||
+5
-5
@@ -1142,8 +1142,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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>
|
||||||
@@ -1152,9 +1152,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></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
|
||||||
|
|||||||
@@ -351,8 +351,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -362,9 +362,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Auth & Init ──────────────────────────────────────────────
|
// ── Auth & Init ──────────────────────────────────────────────
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ $controllerMap = [
|
|||||||
'review-schedule' => 'ReviewScheduleController', // A4 Fase 4.4 — Scadenziario centralizzato (revisioni periodiche GV.PO-02/GV.SC-07/PR.AT/DE.CM)
|
'review-schedule' => 'ReviewScheduleController', // A4 Fase 4.4 — Scadenziario centralizzato (revisioni periodiche GV.PO-02/GV.SC-07/PR.AT/DE.CM)
|
||||||
'framework' => 'FrameworkController', // Epic C / C1 — Elenco canonico Misure/Requisiti (cfg_nis2_*, read-only)
|
'framework' => 'FrameworkController', // Epic C / C1 — Elenco canonico Misure/Requisiti (cfg_nis2_*, read-only)
|
||||||
'stakeholders' => 'StakeholderController', // Epic C / C5 — Registro stakeholder + matrice di Mendelow
|
'stakeholders' => 'StakeholderController', // Epic C / C5 — Registro stakeholder + matrice di Mendelow
|
||||||
|
'stakeholder-activities' => 'StakeholderActivityController', // Epic C / C5.2 — Attività stakeholder (questionari/azioni + calendario)
|
||||||
'assessments' => 'AssessmentController',
|
'assessments' => 'AssessmentController',
|
||||||
'isms' => 'IsmsModelController', // Modello Organizzativo SGSI (ISO 27001/27017/27018 + SoA)
|
'isms' => 'IsmsModelController', // Modello Organizzativo SGSI (ISO 27001/27017/27018 + SoA)
|
||||||
'dashboard' => 'DashboardController',
|
'dashboard' => 'DashboardController',
|
||||||
@@ -442,6 +443,23 @@ $actionMap = [
|
|||||||
'DELETE:{id}' => 'delete',
|
'DELETE:{id}' => 'delete',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// ── StakeholderActivityController (Epic C / C5.2 — attività + questionari tipo) ──
|
||||||
|
'stakeholder-activities' => [
|
||||||
|
'GET:templates' => 'templates',
|
||||||
|
'POST:templates' => 'createTemplate',
|
||||||
|
'GET:templates/{subId}' => 'getTemplate',
|
||||||
|
'PUT:templates/{subId}' => 'updateTemplate',
|
||||||
|
'DELETE:templates/{subId}'=> 'deleteTemplate',
|
||||||
|
'GET:pickers' => 'pickers',
|
||||||
|
'GET:list' => 'list',
|
||||||
|
'POST:create' => 'create',
|
||||||
|
'GET:{id}' => 'get',
|
||||||
|
'PUT:{id}' => 'update',
|
||||||
|
'DELETE:{id}' => 'delete',
|
||||||
|
'POST:{id}/assign' => 'assign',
|
||||||
|
'POST:{id}/send' => 'send',
|
||||||
|
],
|
||||||
|
|
||||||
// ── AuditController ─────────────────────────────
|
// ── AuditController ─────────────────────────────
|
||||||
'audit' => [
|
'audit' => [
|
||||||
'GET:controls' => 'listControls',
|
'GET:controls' => 'listControls',
|
||||||
|
|||||||
@@ -910,8 +910,8 @@ curl -H <span class="str">"X-API-Key: nis2_TUA_CHIAVE"</span> \
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="js/common.js?v=20260626"></script>
|
<script src="js/common.js?v=20260627"></script>
|
||||||
<script src="js/i18n.js?v=20260626"></script>
|
<script src="js/i18n.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
function showTab(id) {
|
function showTab(id) {
|
||||||
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
||||||
|
|||||||
+5
-5
@@ -184,8 +184,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -195,9 +195,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script src="/js/isms.js"></script>
|
<script src="/js/isms.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -309,6 +309,25 @@ class NIS2API {
|
|||||||
stkUpdate(id, d) { return this._acn(this.put(`/stakeholders/${id}`, d || {})); }
|
stkUpdate(id, d) { return this._acn(this.put(`/stakeholders/${id}`, d || {})); }
|
||||||
stkDelete(id) { return this._acn(this.del(`/stakeholders/${id}`)); }
|
stkDelete(id) { return this._acn(this.del(`/stakeholders/${id}`)); }
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Attività stakeholder (Epic C / C5.2). Tutti _acn.
|
||||||
|
// Questionari tipo (template) + attività (questionari/azioni) + assegnazione
|
||||||
|
// (per codice o singoli) + invio (genera magic-link per il portale esterno).
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
stkActTemplates() { return this._acn(this.get('/stakeholder-activities/templates')); }
|
||||||
|
stkActGetTemplate(id) { return this._acn(this.get(`/stakeholder-activities/templates/${id}`)); }
|
||||||
|
stkActCreateTemplate(d) { return this._acn(this.post('/stakeholder-activities/templates', d || {})); }
|
||||||
|
stkActUpdateTemplate(id, d) { return this._acn(this.put(`/stakeholder-activities/templates/${id}`, d || {})); }
|
||||||
|
stkActDeleteTemplate(id) { return this._acn(this.del(`/stakeholder-activities/templates/${id}`)); }
|
||||||
|
stkActPickers() { return this._acn(this.get('/stakeholder-activities/pickers')); }
|
||||||
|
stkActList() { return this._acn(this.get('/stakeholder-activities/list')); }
|
||||||
|
stkActGet(id) { return this._acn(this.get(`/stakeholder-activities/${id}`)); }
|
||||||
|
stkActCreate(d) { return this._acn(this.post('/stakeholder-activities/create', d || {})); }
|
||||||
|
stkActUpdate(id, d) { return this._acn(this.put(`/stakeholder-activities/${id}`, d || {})); }
|
||||||
|
stkActDelete(id) { return this._acn(this.del(`/stakeholder-activities/${id}`)); }
|
||||||
|
stkActAssign(id, d) { return this._acn(this.post(`/stakeholder-activities/${id}/assign`, d || {})); }
|
||||||
|
stkActSend(id) { return this._acn(this.post(`/stakeholder-activities/${id}/send`, {})); }
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// Dashboard
|
// Dashboard
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
{ 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: '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: 'Attività stakeholder', href: 'stakeholder-activities.html', icon: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clip-rule="evenodd"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z" clip-rule="evenodd"/></svg>', i18nKey: 'nav.stk_activities' },
|
||||||
{ 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>' },
|
||||||
|
|||||||
@@ -400,6 +400,40 @@ const HelpSystem = (function () {
|
|||||||
'NOTA: gli obblighi normativi in Italia derivano dal D.Lgs. 138/2024; la matrice e i framework NIST CSF/ACN sono strumenti di supporto, non un parere legale.'
|
'NOTA: gli obblighi normativi in Italia derivano dal D.Lgs. 138/2024; la matrice e i framework NIST CSF/ACN sono strumenti di supporto, non un parere legale.'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
'stakeholder-activities': {
|
||||||
|
title: 'Guida - Attività stakeholder',
|
||||||
|
intro: 'Le attività verso gli stakeholder ti permettono di inviare questionari (da compilare o per firma di avvenuta lettura) e pianificare azioni, collegandoli alle procedure e alle misure/requisiti, con data e scadenza sul calendario NIS2. Strumento di supporto organizzativo, non un parere legale.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'Questionari tipo',
|
||||||
|
items: [
|
||||||
|
'Un <strong>questionario tipo</strong> è un modello riutilizzabile: scegli se è un <strong>questionario da compilare</strong> (con domande) o una <strong>richiesta di firma di avvenuta lettura</strong> (un testo da leggere e confermare).',
|
||||||
|
'Puoi collegare il questionario tipo alle <strong>procedure</strong> e alle <strong>misure/requisiti</strong> del framework (collegamento molti-a-molti), per documentare a cosa si riferisce.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Attività e assegnazione',
|
||||||
|
items: [
|
||||||
|
'Crea un\'<strong>attività</strong> basata (o no) su un questionario tipo e assegnala <strong>per codice stakeholder</strong> (tutti quelli di quel tipo) oppure a <strong>stakeholder singoli</strong> selezionati.',
|
||||||
|
'Indica una <strong>data pianificata</strong> e una <strong>scadenza</strong>: la scadenza compare automaticamente nel <strong>calendario NIS2</strong> (Scadenziario).',
|
||||||
|
'Con <strong>Invia</strong> il sistema genera un <strong>magic-link</strong> per ogni destinatario. Poiché le email sono disattivate, copia e condividi i link manualmente; gli stakeholder rispondono dal portale senza account.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Riferimenti normativi',
|
||||||
|
items: [
|
||||||
|
'<strong>GV.SC-02</strong>: ruoli e responsabilità verso fornitori, clienti e partner.',
|
||||||
|
'<strong>GV.SC-05 / GV.SC-07</strong>: requisiti di sicurezza negli accordi e monitoraggio/revisione delle terze parti nel tempo.',
|
||||||
|
'Le <strong>azioni</strong> correttive/preventive si gestiscono nel modulo NCR/CAPA.'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
references: [
|
||||||
|
'NIST CSF 2.0 / GV.SC-02, GV.SC-05, GV.SC-07 - Coordinamento, accordi e monitoraggio delle terze parti',
|
||||||
|
'Obblighi: gestione della supply chain ai sensi dell\'art. 24 del D.Lgs. 138/2024.',
|
||||||
|
'NOTA: strumento di supporto organizzativo, non un parere legale.'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
// ─── Risk Management ─────────────────────────────────────────
|
// ─── Risk Management ─────────────────────────────────────────
|
||||||
'risks': {
|
'risks': {
|
||||||
@@ -1248,6 +1282,8 @@ const HelpSystem = (function () {
|
|||||||
'misure-requisiti': 'misure-requisiti',
|
'misure-requisiti': 'misure-requisiti',
|
||||||
'stakeholders.html': 'stakeholders',
|
'stakeholders.html': 'stakeholders',
|
||||||
'stakeholders': 'stakeholders',
|
'stakeholders': 'stakeholders',
|
||||||
|
'stakeholder-activities.html': 'stakeholder-activities',
|
||||||
|
'stakeholder-activities': 'stakeholder-activities',
|
||||||
'risks.html': 'risks',
|
'risks.html': 'risks',
|
||||||
'risks': 'risks',
|
'risks': 'risks',
|
||||||
'incidents.html': 'incidents',
|
'incidents.html': 'incidents',
|
||||||
|
|||||||
@@ -72,8 +72,10 @@ const I18n = (function () {
|
|||||||
'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.review_schedule': { it: 'Scadenziario', en: 'Review schedule' },
|
||||||
'nav.stakeholders': { it: 'Stakeholder', en: 'Stakeholders' },
|
'nav.stakeholders': { it: 'Stakeholder', en: 'Stakeholders' },
|
||||||
|
'nav.stk_activities': { it: 'Attività stakeholder', en: 'Stakeholder activities' },
|
||||||
'rev.title': { it: 'Scadenziario', en: 'Review Schedule' },
|
'rev.title': { it: 'Scadenziario', en: 'Review Schedule' },
|
||||||
'stk.title': { it: 'Stakeholder', en: 'Stakeholders' },
|
'stk.title': { it: 'Stakeholder', en: 'Stakeholders' },
|
||||||
|
'sact.title': { it: 'Attività stakeholder', en: 'Stakeholder activities' },
|
||||||
'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' },
|
||||||
|
|||||||
+5
-5
@@ -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=20260626"></script>
|
<script src="/js/common.js?v=20260627"></script>
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></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>
|
||||||
@@ -161,9 +161,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></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.
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ body { background: var(--bg-main); }
|
|||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
|
|
||||||
<script src="js/api.js?v=20260626"></script>
|
<script src="js/api.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
// CONFIG
|
// CONFIG
|
||||||
|
|||||||
+2
-2
@@ -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=20260626"></script>
|
<script src="js/api.js?v=20260627"></script>
|
||||||
<script src="js/common.js?v=20260626"></script>
|
<script src="js/common.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
|
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>Misure e Requisiti - NIS2 Agile</title>
|
<title>Misure e Requisiti - NIS2 Agile</title>
|
||||||
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. -->
|
<!-- 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="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||||
<link rel="stylesheet" href="/css/style.css?v=20260626">
|
<link rel="stylesheet" href="/css/style.css?v=20260627">
|
||||||
<style>
|
<style>
|
||||||
.mr-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:14px; font-size:.92rem; line-height:1.6; }
|
.mr-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:14px; font-size:.92rem; line-height:1.6; }
|
||||||
.mr-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
.mr-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
||||||
@@ -162,12 +162,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
'use strict';
|
'use strict';
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -424,6 +424,6 @@ curl https://nis2.agile.software/api/services/status</pre>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script src="js/common.js?v=20260626"></script>
|
<script src="js/common.js?v=20260627"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -112,8 +112,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -123,9 +123,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
loadSidebar();
|
loadSidebar();
|
||||||
|
|||||||
@@ -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=20260626"></script>
|
<script src="js/api.js?v=20260627"></script>
|
||||||
<script src="js/common.js?v=20260626"></script>
|
<script src="js/common.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Auth check ──────────────────────────────────────────────────
|
// ── Auth check ──────────────────────────────────────────────────
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|||||||
@@ -138,12 +138,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script src="/js/organigramma.js?v=20260617"></script>
|
<script src="/js/organigramma.js?v=20260617"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -333,8 +333,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -344,9 +344,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Auth & Init ─────────────────────────────────────────
|
// ── Auth & Init ─────────────────────────────────────────
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|||||||
+5
-5
@@ -131,12 +131,12 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script src="/js/raci.js?v=20260619"></script>
|
<script src="/js/raci.js?v=20260619"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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=20260626"></script>
|
<script src="js/api.js?v=20260627"></script>
|
||||||
<script src="js/common.js?v=20260626"></script>
|
<script src="js/common.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
|
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -443,8 +443,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -454,9 +454,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Auth & Init ─────────────────────────────────────────
|
// ── Auth & Init ─────────────────────────────────────────
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|||||||
@@ -141,12 +141,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
'use strict';
|
'use strict';
|
||||||
/*
|
/*
|
||||||
|
|||||||
+5
-5
@@ -494,8 +494,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -505,9 +505,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Auth & Init ──────────────────────────────────────────────
|
// ── Auth & Init ──────────────────────────────────────────────
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|||||||
@@ -672,8 +672,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -683,9 +683,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Auth check ───────────────────────────────────────────
|
// ── Auth check ───────────────────────────────────────────
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|||||||
@@ -165,8 +165,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="js/api.js?v=20260626"></script>
|
<script src="js/api.js?v=20260627"></script>
|
||||||
<script src="js/common.js?v=20260626"></script>
|
<script src="js/common.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Auth check ───────────────────────────────────────────
|
// ── Auth check ───────────────────────────────────────────
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Attività stakeholder - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||||
|
<link rel="stylesheet" href="/css/style.css?v=20260627">
|
||||||
|
<style>
|
||||||
|
.sa-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
|
||||||
|
.sa-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
||||||
|
.sa-tabs { display:flex; gap:6px; border-bottom:2px solid var(--gray-200,#e5e7eb); margin-bottom:16px; }
|
||||||
|
.sa-tab { padding:9px 16px; font-weight:600; font-size:.9rem; cursor:pointer; border:none; background:none; color:var(--gray-500,#6b7280); border-bottom:2px solid transparent; margin-bottom:-2px; }
|
||||||
|
.sa-tab.active { color:var(--primary,#2563eb); border-bottom-color:var(--primary,#2563eb); }
|
||||||
|
.sa-toolbar { display:flex; justify-content:flex-end; margin-bottom:12px; }
|
||||||
|
.sa-table { width:100%; border-collapse:collapse; font-size:.88rem; }
|
||||||
|
.sa-table th, .sa-table td { text-align:left; padding:9px 12px; border-top:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
|
||||||
|
.sa-table th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
|
||||||
|
.sa-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; white-space:nowrap; }
|
||||||
|
.sa-badge.k-questionnaire { background:#dbeafe; color:#1e40af; }
|
||||||
|
.sa-badge.k-read_ack { background:#dcfce7; color:#166534; }
|
||||||
|
.sa-badge.k-action { background:#ffedd5; color:#9a3412; }
|
||||||
|
.sa-badge.s-draft { background:#f3f4f6; color:#6b7280; }
|
||||||
|
.sa-badge.s-scheduled { background:#e0f2fe; color:#075985; }
|
||||||
|
.sa-badge.s-sent { background:#ede9fe; color:#5b21b6; }
|
||||||
|
.sa-badge.s-completed { background:#dcfce7; color:#166534; }
|
||||||
|
.sa-badge.s-cancelled { background:#fee2e2; color:#991b1b; }
|
||||||
|
.sa-badge.s-in_progress { background:#fef3c7; color:#92400e; }
|
||||||
|
.sa-empty { text-align:center; padding:22px 16px; color:var(--gray-500,#6b7280); font-size:.86rem; }
|
||||||
|
.sm-overlay { display:none; position:fixed; inset:0; background:rgba(15,23,42,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:30px 16px; }
|
||||||
|
.sm-overlay.open { display:flex; }
|
||||||
|
.sm-dialog { background:#fff; border-radius:12px; width:100%; max-width:640px; 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 .hint { font-weight:400; color:var(--gray-500,#6b7280); font-size:.78rem; }
|
||||||
|
.sm-field input[type=text], .sm-field input[type=date], .sm-field select, .sm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; background:#fff; }
|
||||||
|
.sm-field select[multiple] { min-height:96px; }
|
||||||
|
.sm-field textarea { min-height:60px; resize:vertical; }
|
||||||
|
.sm-row { display:flex; gap:12px; flex-wrap:wrap; }
|
||||||
|
.sm-row > .sm-field { flex:1 1 0; min-width:150px; }
|
||||||
|
.sm-err { color:#b91c1c; font-size:.82rem; min-height:18px; }
|
||||||
|
.sm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); }
|
||||||
|
.hidden { display:none !important; }
|
||||||
|
.q-row { display:flex; gap:8px; align-items:flex-start; margin-bottom:8px; }
|
||||||
|
.q-row input[type=text] { flex:1 1 auto; }
|
||||||
|
.q-row select { flex:0 0 130px; }
|
||||||
|
.q-del { background:none; border:1px solid #fecaca; color:#b91c1c; border-radius:6px; padding:6px 9px; cursor:pointer; }
|
||||||
|
.lnk-box { background:#f8fafc; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; padding:10px 12px; margin-top:10px; font-size:.82rem; }
|
||||||
|
.lnk-item { display:flex; gap:8px; align-items:center; padding:5px 0; border-top:1px solid var(--gray-100,#f3f4f6); }
|
||||||
|
.lnk-item:first-child { border-top:none; }
|
||||||
|
.lnk-item code { background:#eef2ff; padding:2px 6px; border-radius:5px; font-size:.76rem; flex:1 1 auto; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.tg-state { font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; }
|
||||||
|
.tg-pending { background:#f3f4f6; color:#6b7280; } .tg-sent { background:#ede9fe; color:#5b21b6; }
|
||||||
|
.tg-responded, .tg-acknowledged { background:#dcfce7; color:#166534; } .tg-expired { background:#fee2e2; color:#991b1b; }
|
||||||
|
</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="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-title" 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="sact.title">Attività stakeholder</h2>
|
||||||
|
</header>
|
||||||
|
<div class="content-body">
|
||||||
|
<div class="sa-intro">
|
||||||
|
<strong>Attività verso gli stakeholder.</strong>
|
||||||
|
Crea <em>questionari tipo</em> (da compilare o per firma di avvenuta lettura) collegati a procedure e a misure/requisiti,
|
||||||
|
poi pianifica <em>attività</em> assegnandole a un codice stakeholder o a singoli stakeholder, con data e scadenza sul calendario NIS2.
|
||||||
|
</div>
|
||||||
|
<div class="sa-note">
|
||||||
|
Ancoraggio: <strong>GV.SC-02 / GV.SC-05 / GV.SC-07</strong> (coordinamento, requisiti e monitoraggio verso terze parti) —
|
||||||
|
obblighi ai sensi dell'<strong>art. 24 D.Lgs. 138/2024</strong>. L'invio email è disattivato: i questionari si condividono tramite magic-link.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sa-tabs">
|
||||||
|
<button class="sa-tab active" id="tab-act" onclick="saTab('act')">Attività</button>
|
||||||
|
<button class="sa-tab" id="tab-tpl" onclick="saTab('tpl')">Questionari tipo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ATTIVITÀ -->
|
||||||
|
<section id="pane-act">
|
||||||
|
<div class="sa-toolbar"><button class="btn btn-primary btn-sm" onclick="actOpen()">+ Nuova attività</button></div>
|
||||||
|
<div id="act-list" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- QUESTIONARI TIPO -->
|
||||||
|
<section id="pane-tpl" class="hidden">
|
||||||
|
<div class="sa-toolbar"><button class="btn btn-primary btn-sm" onclick="tplOpen()">+ Nuovo questionario tipo</button></div>
|
||||||
|
<div id="tpl-list" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modale questionario tipo -->
|
||||||
|
<div class="sm-overlay" id="tpl-modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="sm-dialog">
|
||||||
|
<div class="sm-head"><h3 id="tpl-modal-title">Nuovo questionario tipo</h3><button class="sm-close" type="button" onclick="tplClose()">×</button></div>
|
||||||
|
<div class="sm-body">
|
||||||
|
<input type="hidden" id="tpl-id">
|
||||||
|
<div class="sm-row">
|
||||||
|
<div class="sm-field"><label for="tpl-name">Nome *</label><input type="text" id="tpl-name" maxlength="255"></div>
|
||||||
|
<div class="sm-field"><label for="tpl-kind">Tipo *</label>
|
||||||
|
<select id="tpl-kind" onchange="tplOnKind()">
|
||||||
|
<option value="questionnaire">Questionario da compilare</option>
|
||||||
|
<option value="read_ack">Firma di avvenuta lettura</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sm-field"><label for="tpl-descr">Descrizione</label><textarea id="tpl-descr" maxlength="1000"></textarea></div>
|
||||||
|
<div class="sm-field hidden" id="tpl-wrap-content"><label for="tpl-content">Testo da leggere <span class="hint">(per firma di avvenuta lettura)</span></label><textarea id="tpl-content" style="min-height:110px;"></textarea></div>
|
||||||
|
<div class="sm-field" id="tpl-wrap-questions">
|
||||||
|
<label>Domande</label>
|
||||||
|
<div id="tpl-questions"></div>
|
||||||
|
<button class="btn btn-outline btn-sm" type="button" onclick="qAdd()">+ Aggiungi domanda</button>
|
||||||
|
</div>
|
||||||
|
<div class="sm-row">
|
||||||
|
<div class="sm-field"><label for="tpl-proc">Procedure collegate</label><select id="tpl-proc" multiple></select></div>
|
||||||
|
<div class="sm-field"><label for="tpl-mis">Misure collegate</label><select id="tpl-mis" multiple></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="sm-field"><label for="tpl-req">Requisiti collegati</label><select id="tpl-req" multiple></select></div>
|
||||||
|
<div class="sm-err" id="tpl-err" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sm-foot"><button class="btn btn-outline" type="button" onclick="tplClose()">Annulla</button><button class="btn btn-primary" type="button" id="tpl-save" onclick="tplSave()">Salva</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modale attività -->
|
||||||
|
<div class="sm-overlay" id="act-modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="sm-dialog">
|
||||||
|
<div class="sm-head"><h3 id="act-modal-title">Nuova attività</h3><button class="sm-close" type="button" onclick="actClose()">×</button></div>
|
||||||
|
<div class="sm-body">
|
||||||
|
<input type="hidden" id="act-id">
|
||||||
|
<div class="sm-row">
|
||||||
|
<div class="sm-field"><label for="act-title">Titolo *</label><input type="text" id="act-title" maxlength="255"></div>
|
||||||
|
<div class="sm-field"><label for="act-type">Tipo *</label>
|
||||||
|
<select id="act-type">
|
||||||
|
<option value="questionnaire">Questionario</option>
|
||||||
|
<option value="read_ack">Firma di avvenuta lettura</option>
|
||||||
|
<option value="action">Azione</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sm-field"><label for="act-template">Questionario tipo <span class="hint">(opzionale)</span></label><select id="act-template"><option value="">— Nessuno —</option></select></div>
|
||||||
|
<div class="sm-field"><label for="act-descr">Descrizione</label><textarea id="act-descr" maxlength="1000"></textarea></div>
|
||||||
|
<div class="sm-row">
|
||||||
|
<div class="sm-field"><label for="act-mode">Assegnazione *</label>
|
||||||
|
<select id="act-mode" onchange="actOnMode()">
|
||||||
|
<option value="by_code">Per codice stakeholder</option>
|
||||||
|
<option value="individual">Stakeholder singoli</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sm-field" id="act-wrap-code"><label for="act-code">Codice stakeholder</label><select id="act-code"></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="sm-field hidden" id="act-wrap-individual"><label for="act-stakeholders">Stakeholder <span class="hint">(Ctrl/Cmd per più selezioni)</span></label><select id="act-stakeholders" multiple></select></div>
|
||||||
|
<div class="sm-row">
|
||||||
|
<div class="sm-field"><label for="act-planned">Data pianificata</label><input type="date" id="act-planned"></div>
|
||||||
|
<div class="sm-field"><label for="act-due">Scadenza <span class="hint">(→ calendario NIS2)</span></label><input type="date" id="act-due"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sm-field"><label for="act-proc">Procedure collegate</label><select id="act-proc" multiple></select></div>
|
||||||
|
<div class="sm-err" id="act-err" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sm-foot"><button class="btn btn-outline" type="button" onclick="actClose()">Annulla</button><button class="btn btn-primary" type="button" id="act-save" onclick="actSave()">Salva</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modale dettaglio attività (target + invio) -->
|
||||||
|
<div class="sm-overlay" id="det-modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="sm-dialog">
|
||||||
|
<div class="sm-head"><h3 id="det-title">Dettaglio attività</h3><button class="sm-close" type="button" onclick="detClose()">×</button></div>
|
||||||
|
<div class="sm-body" id="det-body"></div>
|
||||||
|
<div class="sm-foot">
|
||||||
|
<button class="btn btn-outline" type="button" onclick="detClose()">Chiudi</button>
|
||||||
|
<button class="btn btn-outline" type="button" id="det-assign" onclick="detAssign()">Assegna destinatari</button>
|
||||||
|
<button class="btn btn-primary" type="button" id="det-send" onclick="detSend()">Invia (genera magic-link)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
|
<script src="/js/common.js?v=20260627"></script>
|
||||||
|
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||||
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
/*
|
||||||
|
* NIS2 Agile - Attività stakeholder (Epic C / C5.2a)
|
||||||
|
* Questionari tipo (template) + attività + assegnazione (per codice / singoli) + invio
|
||||||
|
* con magic-link (email disattivate). Sotto-dashboard feedback completa in C5.2b.
|
||||||
|
*/
|
||||||
|
let SA = { activities: [], templates: [], pickers: { policies: [], misure: [], requisiti: [] }, types: [], stakeholders: [], detail: null };
|
||||||
|
|
||||||
|
function el(id) { return document.getElementById(id); }
|
||||||
|
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async function () {
|
||||||
|
if (typeof checkAuth === 'function' && !checkAuth()) return;
|
||||||
|
if (window.I18n && I18n.init) I18n.init('it');
|
||||||
|
if (typeof loadSidebar === 'function') loadSidebar();
|
||||||
|
if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') { tplClose(); actClose(); detClose(); } });
|
||||||
|
['tpl-modal','act-modal','det-modal'].forEach(m => { const o = el(m); if (o) o.addEventListener('click', e => { if (e.target === o) o.classList.remove('open'); }); });
|
||||||
|
await saLoadAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
function saTab(which) {
|
||||||
|
el('tab-act').classList.toggle('active', which === 'act');
|
||||||
|
el('tab-tpl').classList.toggle('active', which === 'tpl');
|
||||||
|
el('pane-act').classList.toggle('hidden', which !== 'act');
|
||||||
|
el('pane-tpl').classList.toggle('hidden', which !== 'tpl');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saLoadAll() {
|
||||||
|
el('act-list').innerHTML = '<div class="sa-empty">Caricamento…</div>';
|
||||||
|
el('tpl-list').innerHTML = '<div class="sa-empty">Caricamento…</div>';
|
||||||
|
try {
|
||||||
|
const [acts, tpls, pickers, types, stks] = await Promise.all([
|
||||||
|
api.stkActList(), api.stkActTemplates(), api.stkActPickers(), api.stkTypes(), api.stkList()
|
||||||
|
]);
|
||||||
|
SA.activities = (acts && acts.activities) || [];
|
||||||
|
SA.templates = (tpls && tpls.templates) || [];
|
||||||
|
SA.pickers = pickers || SA.pickers;
|
||||||
|
SA.types = (types && types.types) || [];
|
||||||
|
SA.stakeholders = (stks && stks.stakeholders) || [];
|
||||||
|
fillFormSelects();
|
||||||
|
renderActivities();
|
||||||
|
renderTemplates();
|
||||||
|
} catch (e) {
|
||||||
|
el('act-list').innerHTML = '<div class="sa-empty">Errore: ' + esc(e.message || e) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillFormSelects() {
|
||||||
|
const polOpts = (SA.pickers.policies || []).map(p => '<option value="' + p.id + '">' + esc(p.title) + '</option>').join('');
|
||||||
|
el('tpl-proc').innerHTML = polOpts || '<option disabled>Nessuna procedura</option>';
|
||||||
|
el('act-proc').innerHTML = polOpts || '<option disabled>Nessuna procedura</option>';
|
||||||
|
el('tpl-mis').innerHTML = (SA.pickers.misure || []).map(m => '<option value="' + esc(m.code) + '">' + esc(m.code) + ' — ' + esc((m.descr || '').slice(0, 60)) + '</option>').join('');
|
||||||
|
el('tpl-req').innerHTML = (SA.pickers.requisiti || []).map(r => '<option value="' + r.id + '">' + esc(r.code) + ' (' + esc(r.misura_code) + ')</option>').join('');
|
||||||
|
el('act-template').innerHTML = '<option value="">— Nessuno —</option>' + SA.templates.map(t => '<option value="' + t.id + '">' + esc(t.name) + '</option>').join('');
|
||||||
|
el('act-code').innerHTML = SA.types.map(t => '<option value="' + esc(t.code) + '">' + esc(t.code) + ' — ' + esc((t.descr || '').slice(0, 50)) + '</option>').join('');
|
||||||
|
el('act-stakeholders').innerHTML = SA.stakeholders.map(s => '<option value="' + s.id + '">' + esc(s.name) + ' (' + esc(s.stak_code) + ')</option>').join('') || '<option disabled>Nessuno stakeholder: creane nel registro</option>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Questionari tipo ──
|
||||||
|
function renderTemplates() {
|
||||||
|
if (!SA.templates.length) { el('tpl-list').innerHTML = '<div class="sa-empty">Nessun questionario tipo. Creane uno con "+ Nuovo questionario tipo".</div>'; return; }
|
||||||
|
const rows = SA.templates.map(t =>
|
||||||
|
'<tr><td><strong>' + esc(t.name) + '</strong>' + (t.description ? '<div style="font-size:.78rem;color:#6b7280;">' + esc(t.description.slice(0, 90)) + '</div>' : '') + '</td>'
|
||||||
|
+ '<td><span class="sa-badge k-' + t.kind + '">' + (t.kind === 'read_ack' ? 'Firma lettura' : 'Questionario') + '</span></td>'
|
||||||
|
+ '<td>' + (t.kind === 'read_ack' ? '—' : (t.n_questions + ' dom.')) + '</td>'
|
||||||
|
+ '<td style="font-size:.8rem;">' + t.n_proc + ' proc · ' + t.n_mis + ' mis · ' + t.n_req + ' req</td>'
|
||||||
|
+ '<td style="white-space:nowrap;"><button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="tplOpen(' + t.id + ')">Modifica</button> '
|
||||||
|
+ '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;color:#b91c1c;border-color:#fecaca;" onclick="tplDelete(' + t.id + ')">Elimina</button></td></tr>'
|
||||||
|
).join('');
|
||||||
|
el('tpl-list').innerHTML = '<table class="sa-table"><thead><tr><th>Nome</th><th>Tipo</th><th>Domande</th><th>Collegamenti</th><th>Azioni</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function qAdd(text, type, required) {
|
||||||
|
const allowed = ['text','yes_no','single_choice','multi_choice','scale_1_5','number'];
|
||||||
|
const t = allowed.indexOf(type) !== -1 ? type : 'text';
|
||||||
|
const div = document.createElement('div'); div.className = 'q-row';
|
||||||
|
div.innerHTML = '<input type="text" placeholder="Testo della domanda" maxlength="500" value="' + esc(text || '').replace(/"/g,'"') + '">'
|
||||||
|
+ '<select>' + allowed.map(a => '<option value="' + a + '"' + (a === t ? ' selected' : '') + '>' + ({text:'Testo',yes_no:'Sì/No',single_choice:'Scelta singola',multi_choice:'Scelta multipla',scale_1_5:'Scala 1-5',number:'Numero'})[a] + '</option>').join('') + '</select>'
|
||||||
|
+ '<label style="font-size:.78rem;display:flex;align-items:center;gap:4px;white-space:nowrap;"><input type="checkbox"' + (required ? ' checked' : '') + '> obbl.</label>'
|
||||||
|
+ '<button type="button" class="q-del" onclick="this.parentNode.remove()">✕</button>';
|
||||||
|
el('tpl-questions').appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tplOnKind() {
|
||||||
|
const k = el('tpl-kind').value;
|
||||||
|
el('tpl-wrap-content').classList.toggle('hidden', k !== 'read_ack');
|
||||||
|
el('tpl-wrap-questions').classList.toggle('hidden', k === 'read_ack');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tplOpen(id) {
|
||||||
|
el('tpl-id').value = id || '';
|
||||||
|
el('tpl-modal-title').textContent = id ? 'Modifica questionario tipo' : 'Nuovo questionario tipo';
|
||||||
|
el('tpl-err').textContent = '';
|
||||||
|
el('tpl-questions').innerHTML = '';
|
||||||
|
['tpl-proc','tpl-mis','tpl-req'].forEach(s => Array.prototype.forEach.call(el(s).options, o => o.selected = false));
|
||||||
|
if (id) {
|
||||||
|
try {
|
||||||
|
const t = await api.stkActGetTemplate(id);
|
||||||
|
el('tpl-name').value = t.name || ''; el('tpl-kind').value = t.kind || 'questionnaire';
|
||||||
|
el('tpl-descr').value = t.description || ''; el('tpl-content').value = t.content || '';
|
||||||
|
(t.questions || []).forEach(q => qAdd(q.text, q.type, q.required));
|
||||||
|
selMulti('tpl-proc', t.policy_ids || []); selMulti('tpl-mis', t.misura_codes || []); selMulti('tpl-req', t.requisito_ids || []);
|
||||||
|
} catch (e) { el('tpl-err').textContent = e.message || 'Errore'; }
|
||||||
|
} else {
|
||||||
|
el('tpl-name').value = ''; el('tpl-kind').value = 'questionnaire'; el('tpl-descr').value = ''; el('tpl-content').value = '';
|
||||||
|
}
|
||||||
|
tplOnKind();
|
||||||
|
el('tpl-modal').classList.add('open');
|
||||||
|
}
|
||||||
|
function tplClose() { el('tpl-modal').classList.remove('open'); }
|
||||||
|
|
||||||
|
function collectQuestions() {
|
||||||
|
return Array.prototype.map.call(el('tpl-questions').children, (row, i) => {
|
||||||
|
const inp = row.querySelector('input[type=text]'); const sel = row.querySelector('select'); const chk = row.querySelector('input[type=checkbox]');
|
||||||
|
return { code: 'Q' + (i + 1), text: inp.value.trim(), type: sel.value, required: chk.checked };
|
||||||
|
}).filter(q => q.text !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tplSave() {
|
||||||
|
const id = el('tpl-id').value;
|
||||||
|
const name = el('tpl-name').value.trim();
|
||||||
|
if (!name) { el('tpl-err').textContent = 'Il nome è obbligatorio.'; return; }
|
||||||
|
const kind = el('tpl-kind').value;
|
||||||
|
const payload = {
|
||||||
|
name: name, kind: kind, description: el('tpl-descr').value.trim(),
|
||||||
|
content: kind === 'read_ack' ? el('tpl-content').value.trim() : null,
|
||||||
|
questions: kind === 'questionnaire' ? collectQuestions() : [],
|
||||||
|
policy_ids: getMulti('tpl-proc'), misura_codes: getMulti('tpl-mis', true), requisito_ids: getMulti('tpl-req'),
|
||||||
|
};
|
||||||
|
const btn = el('tpl-save'); btn.disabled = true;
|
||||||
|
try {
|
||||||
|
if (id) await api.stkActUpdateTemplate(parseInt(id, 10), payload); else await api.stkActCreateTemplate(payload);
|
||||||
|
showNotification(id ? 'Questionario tipo aggiornato.' : 'Questionario tipo creato.', 'success');
|
||||||
|
tplClose(); await saLoadAll();
|
||||||
|
} catch (e) { el('tpl-err').textContent = e.message || 'Errore nel salvataggio.'; } finally { btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tplDelete(id) {
|
||||||
|
const t = SA.templates.find(x => x.id === id);
|
||||||
|
if (!confirm('Eliminare il questionario tipo "' + (t ? t.name : '') + '"?')) return;
|
||||||
|
try { await api.stkActDeleteTemplate(id); showNotification('Eliminato.', 'success'); await saLoadAll(); }
|
||||||
|
catch (e) { showNotification(e.message || 'Errore', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attività ──
|
||||||
|
function renderActivities() {
|
||||||
|
if (!SA.activities.length) { el('act-list').innerHTML = '<div class="sa-empty">Nessuna attività. Creane una con "+ Nuova attività".</div>'; return; }
|
||||||
|
const rows = SA.activities.map(a =>
|
||||||
|
'<tr><td><strong>' + esc(a.title) + '</strong>' + (a.template_name ? '<div style="font-size:.78rem;color:#6b7280;">' + esc(a.template_name) + '</div>' : '') + '</td>'
|
||||||
|
+ '<td><span class="sa-badge k-' + a.type + '">' + ({questionnaire:'Questionario',read_ack:'Firma lettura',action:'Azione'})[a.type] + '</span></td>'
|
||||||
|
+ '<td style="font-size:.82rem;">' + (a.assign_mode === 'by_code' ? ('Codice ' + esc(a.stak_code || '?')) : 'Singoli') + '</td>'
|
||||||
|
+ '<td style="font-size:.82rem;">' + (a.due_date ? esc(a.due_date) : '—') + '</td>'
|
||||||
|
+ '<td><span class="sa-badge s-' + a.status + '">' + esc(a.status) + '</span></td>'
|
||||||
|
+ '<td style="font-size:.82rem;">' + a.n_done + '/' + a.n_targets + '</td>'
|
||||||
|
+ '<td style="white-space:nowrap;"><button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="detOpen(' + a.id + ')">Dettaglio</button> '
|
||||||
|
+ '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="actOpen(' + a.id + ')">Modifica</button> '
|
||||||
|
+ '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;color:#b91c1c;border-color:#fecaca;" onclick="actDelete(' + a.id + ')">Elimina</button></td></tr>'
|
||||||
|
).join('');
|
||||||
|
el('act-list').innerHTML = '<table class="sa-table"><thead><tr><th>Titolo</th><th>Tipo</th><th>Assegnazione</th><th>Scadenza</th><th>Stato</th><th>Risposte</th><th>Azioni</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function actOnMode() {
|
||||||
|
const m = el('act-mode').value;
|
||||||
|
el('act-wrap-code').classList.toggle('hidden', m !== 'by_code');
|
||||||
|
el('act-wrap-individual').classList.toggle('hidden', m !== 'individual');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function actOpen(id) {
|
||||||
|
el('act-id').value = id || '';
|
||||||
|
el('act-modal-title').textContent = id ? 'Modifica attività' : 'Nuova attività';
|
||||||
|
el('act-err').textContent = '';
|
||||||
|
let a = null;
|
||||||
|
if (id) { try { a = await api.stkActGet(id); } catch (e) { el('act-err').textContent = e.message; } }
|
||||||
|
el('act-title').value = a ? a.title : '';
|
||||||
|
el('act-type').value = a ? a.type : 'questionnaire';
|
||||||
|
el('act-template').value = a && a.template_id ? String(a.template_id) : '';
|
||||||
|
el('act-descr').value = a ? (a.description || '') : '';
|
||||||
|
el('act-mode').value = a ? a.assign_mode : 'by_code';
|
||||||
|
el('act-code').value = a && a.stak_code ? a.stak_code : (el('act-code').querySelector('option') ? el('act-code').querySelector('option').value : '');
|
||||||
|
el('act-planned').value = a ? (a.planned_date || '') : '';
|
||||||
|
el('act-due').value = a ? (a.due_date || '') : '';
|
||||||
|
selMulti('act-proc', a ? (a.policy_ids || []) : []);
|
||||||
|
// gli stakeholder individuali correnti = i target esistenti
|
||||||
|
selMulti('act-stakeholders', a && a.targets ? a.targets.map(t => t.stakeholder_id) : []);
|
||||||
|
actOnMode();
|
||||||
|
el('act-modal').classList.add('open');
|
||||||
|
}
|
||||||
|
function actClose() { el('act-modal').classList.remove('open'); }
|
||||||
|
|
||||||
|
async function actSave() {
|
||||||
|
const id = el('act-id').value;
|
||||||
|
const title = el('act-title').value.trim();
|
||||||
|
if (!title) { el('act-err').textContent = 'Il titolo è obbligatorio.'; return; }
|
||||||
|
const mode = el('act-mode').value;
|
||||||
|
const payload = {
|
||||||
|
title: title, type: el('act-type').value,
|
||||||
|
template_id: el('act-template').value ? parseInt(el('act-template').value, 10) : null,
|
||||||
|
description: el('act-descr').value.trim(),
|
||||||
|
assign_mode: mode, stak_code: mode === 'by_code' ? el('act-code').value : null,
|
||||||
|
planned_date: el('act-planned').value || null, due_date: el('act-due').value || null,
|
||||||
|
policy_ids: getMulti('act-proc'),
|
||||||
|
};
|
||||||
|
const btn = el('act-save'); btn.disabled = true;
|
||||||
|
try {
|
||||||
|
let actId;
|
||||||
|
if (id) { await api.stkActUpdate(parseInt(id, 10), payload); actId = parseInt(id, 10); }
|
||||||
|
else { const r = await api.stkActCreate(payload); actId = r.id; }
|
||||||
|
// assegnazione immediata
|
||||||
|
if (mode === 'individual') {
|
||||||
|
const ids = getMulti('act-stakeholders');
|
||||||
|
if (ids.length) await api.stkActAssign(actId, { stakeholder_ids: ids });
|
||||||
|
} else {
|
||||||
|
await api.stkActAssign(actId, {});
|
||||||
|
}
|
||||||
|
showNotification(id ? 'Attività aggiornata.' : 'Attività creata e destinatari assegnati.', 'success');
|
||||||
|
actClose(); await saLoadAll();
|
||||||
|
} catch (e) { el('act-err').textContent = e.message || 'Errore nel salvataggio.'; } finally { btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function actDelete(id) {
|
||||||
|
const a = SA.activities.find(x => x.id === id);
|
||||||
|
if (!confirm('Eliminare l\'attività "' + (a ? a.title : '') + '"?')) return;
|
||||||
|
try { await api.stkActDelete(id); showNotification('Eliminata.', 'success'); await saLoadAll(); }
|
||||||
|
catch (e) { showNotification(e.message || 'Errore', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dettaglio + invio ──
|
||||||
|
async function detOpen(id) {
|
||||||
|
el('det-body').innerHTML = '<div class="sa-empty">Caricamento…</div>';
|
||||||
|
el('det-modal').classList.add('open');
|
||||||
|
try {
|
||||||
|
const a = await api.stkActGet(id);
|
||||||
|
SA.detail = a;
|
||||||
|
detRender(a);
|
||||||
|
} catch (e) { el('det-body').innerHTML = '<div class="sa-empty">Errore: ' + esc(e.message) + '</div>'; }
|
||||||
|
}
|
||||||
|
function detClose() { el('det-modal').classList.remove('open'); SA.detail = null; }
|
||||||
|
|
||||||
|
function detRender(a, links) {
|
||||||
|
el('det-title').textContent = 'Dettaglio: ' + a.title;
|
||||||
|
const targets = a.targets || [];
|
||||||
|
let html = '<div style="font-size:.85rem;margin-bottom:10px;color:#374151;">Tipo: <strong>' + esc(a.type) + '</strong> · Scadenza: ' + esc(a.due_date || '—') + ' · Stato: ' + esc(a.status) + '</div>';
|
||||||
|
if (!targets.length) {
|
||||||
|
html += '<div class="sa-empty">Nessun destinatario assegnato. Usa "Assegna destinatari".</div>';
|
||||||
|
} else {
|
||||||
|
html += '<table class="sa-table"><thead><tr><th>Stakeholder</th><th>Codice</th><th>Stato</th></tr></thead><tbody>'
|
||||||
|
+ targets.map(t => '<tr><td>' + esc(t.stakeholder_name) + '</td><td>' + esc(t.stak_code) + '</td><td><span class="tg-state tg-' + t.state + '">' + esc(t.state) + '</span></td></tr>').join('')
|
||||||
|
+ '</tbody></table>';
|
||||||
|
}
|
||||||
|
if (links && links.length) {
|
||||||
|
html += '<div class="lnk-box"><strong>Magic-link generati</strong> (email disattivate: copia e condividi manualmente):'
|
||||||
|
+ links.map(l => '<div class="lnk-item"><span style="flex:0 0 120px;">' + esc(l.stakeholder_name) + '</span><code id="lnk-' + l.target_id + '">' + esc(location.origin + l.magic_link) + '</code>'
|
||||||
|
+ '<button class="btn btn-outline" style="padding:2px 8px;font-size:.72rem;" onclick="lnkCopy(' + l.target_id + ')">Copia</button></div>').join('')
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
el('det-body').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lnkCopy(tid) {
|
||||||
|
const c = el('lnk-' + tid); if (!c) return;
|
||||||
|
navigator.clipboard && navigator.clipboard.writeText(c.textContent).then(() => showNotification('Link copiato.', 'success'), () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detAssign() {
|
||||||
|
if (!SA.detail) return;
|
||||||
|
const btn = el('det-assign'); btn.disabled = true;
|
||||||
|
try {
|
||||||
|
// per by_code basta richiamare assign; per individual riusa i target correnti (no-op) → suggerisci modifica
|
||||||
|
const r = await api.stkActAssign(SA.detail.id, SA.detail.assign_mode === 'individual' ? { stakeholder_ids: (SA.detail.targets || []).map(t => t.stakeholder_id) } : {});
|
||||||
|
showNotification('Destinatari aggiornati (' + r.total_targets + ').', 'success');
|
||||||
|
await detOpen(SA.detail.id); await saLoadAll();
|
||||||
|
} catch (e) { showNotification(e.message || 'Errore', 'error'); } finally { btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detSend() {
|
||||||
|
if (!SA.detail) return;
|
||||||
|
if (!confirm('Generare i magic-link per tutti i destinatari? (le email sono disattivate, i link andranno condivisi a mano)')) return;
|
||||||
|
const btn = el('det-send'); btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await api.stkActSend(SA.detail.id);
|
||||||
|
showNotification('Inviata: ' + r.sent + ' magic-link generati.', 'success');
|
||||||
|
const a = await api.stkActGet(SA.detail.id); SA.detail = a;
|
||||||
|
detRender(a, r.links);
|
||||||
|
await saLoadAll();
|
||||||
|
} catch (e) { showNotification(e.message || 'Errore', 'error'); } finally { btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── util multi-select ──
|
||||||
|
function getMulti(id, asString) {
|
||||||
|
return Array.prototype.filter.call(el(id).options, o => o.selected).map(o => asString ? o.value : parseInt(o.value, 10)).filter(v => asString ? v : !isNaN(v));
|
||||||
|
}
|
||||||
|
function selMulti(id, values) {
|
||||||
|
const set = new Set((values || []).map(String));
|
||||||
|
Array.prototype.forEach.call(el(id).options, o => { o.selected = set.has(String(o.value)); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>Stakeholder - NIS2 Agile</title>
|
<title>Stakeholder - NIS2 Agile</title>
|
||||||
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. -->
|
<!-- 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="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||||
<link rel="stylesheet" href="/css/style.css?v=20260626">
|
<link rel="stylesheet" href="/css/style.css?v=20260627">
|
||||||
<style>
|
<style>
|
||||||
.stk-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
|
.stk-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
|
||||||
.stk-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
.stk-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
||||||
@@ -191,12 +191,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
'use strict';
|
'use strict';
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -477,8 +477,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -488,9 +488,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Auth & Init ─────────────────────────────────────────
|
// ── Auth & Init ─────────────────────────────────────────
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
* Il nome cache include la release: va BUMPATO ad ogni rilascio (vedi version.json),
|
* Il nome cache include la release: va BUMPATO ad ogni rilascio (vedi version.json),
|
||||||
* cosi' activate elimina automaticamente la shell vecchia.
|
* cosi' activate elimina automaticamente la shell vecchia.
|
||||||
*/
|
*/
|
||||||
const CACHE = 'nis2-shell-v1.19.0';
|
const CACHE = 'nis2-shell-v1.20.0';
|
||||||
|
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/offline.html',
|
'/offline.html',
|
||||||
|
|||||||
@@ -292,8 +292,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js?v=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -303,9 +303,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Auth & Init ─────────────────────────────────────────
|
// ── Auth & Init ─────────────────────────────────────────
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version": "1.19.0", "build": "2026-06-16-v1.19.0", "date": "2026-06-16", "changelog": "Epic C / C5.1 - Modulo Stakeholder: registro dedicato con tipo configurabile (Stak.01-30 di sistema + org-added da Stak.31), doppia valutazione potere/interesse 0-5, collegamento all'organigramma (interni) e a un fornitore Supply Chain (esterni), procedure m2m, e matrice di Mendelow a 4 quadranti (buona prassi, non obbligo NIS2; obbligo = GV.SC-02). mig.051: cfg_stakeholder_types, cfg_stakeholder_quadrants, stakeholders, stakeholder_procedures. StakeholderController + endpoint /api/stakeholders/*; stakeholders.html riscritta con matrice SVG dependency-free; voce sidebar V2 (common-bi.js). Additivo."}
|
{"version": "1.20.0", "build": "2026-06-16-v1.20.0", "date": "2026-06-16", "changelog": "Epic C / C5.2a - Attività stakeholder (fondazione): questionari tipo (template) con domande + m2m a procedure e misure/requisiti; attività assegnabili per codice stakeholder o a singoli; pianificazione data/scadenza sul calendario NIS2 (review_schedule esteso); invio con magic-link per destinatario (email disattivate -> condivisione manuale). mig.052: stk_questionnaire_templates, stk_template_(procedures|misure|requisiti), stk_activities, stk_activity_targets, stk_activity_procedures. StakeholderActivityController + endpoint /api/stakeholder-activities/*; pagina stakeholder-activities.html; voce sidebar V2. Additivo. (C5.2b: portale esterno + sotto-dashboard feedback.)"}
|
||||||
|
|||||||
@@ -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=20260626"></script>
|
<script src="/js/api.js?v=20260627"></script>
|
||||||
<script src="/js/common.js?v=20260626"></script>
|
<script src="/js/common.js?v=20260627"></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). -->
|
||||||
@@ -218,9 +218,9 @@
|
|||||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common-bi.js?v=20260626"></script>
|
<script src="/js/common-bi.js?v=20260627"></script>
|
||||||
<script src="/js/i18n.js?v=20260626"></script>
|
<script src="/js/i18n.js?v=20260627"></script>
|
||||||
<script src="/js/help.js?v=20260626"></script>
|
<script src="/js/help.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
loadSidebar();
|
loadSidebar();
|
||||||
|
|||||||
@@ -319,9 +319,9 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="js/api.js?v=20260626"></script>
|
<script src="js/api.js?v=20260627"></script>
|
||||||
<script src="js/common.js?v=20260626"></script>
|
<script src="js/common.js?v=20260627"></script>
|
||||||
<script src="js/i18n.js?v=20260626"></script>
|
<script src="js/i18n.js?v=20260627"></script>
|
||||||
<script>
|
<script>
|
||||||
if (!checkAuth()) throw new Error('Not authenticated');
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
loadSidebar();
|
loadSidebar();
|
||||||
|
|||||||
Reference in New Issue
Block a user