[FEAT] Gestione documentale ISMS — ciclo di vita ISO 27001 cl.7.5 + export Word (v1.24.0)
Ogni documento del Modello SGSI ha un ciclo di vita completo Bozza->In revisione->Approvato->Pubblicato->Archiviato con tracciamento (revisore/approvatore/pubblicatore, date, entrata in vigore, prossimo riesame) e storico versioni (snapshot). Download Word .doc modificabile per documento.
- mig.060: stati published/archived + 10 colonne tracciamento + tabella isms_document_versions
- IsmsModelController: submit/approve/publish/reject/archive/newVersion/versions/exportWord + listDocuments arricchito
- index.php: 8 route documents/{id}/(submit|approve|publish|reject|archive|newVersion|versions|word)
- isms.js: UI badge stato + pulsanti transizione + download Word + storico (IT/EN inline); cache-buster
- help.js: sezione 'Ciclo di vita dei documenti (cl.7.5)'
- 31 documenti di Nuova Agile pubblicati v1.0 (build_nuova_agile_p6_publish)
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
94e3a4293e
commit
68694a0f38
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* build_nuova_agile_p6_publish.php — Pubblica i documenti SGSI di Nuova Agile (ciclo di vita).
|
||||
* approvato -> pubblicato (in vigore) con traccia approvazione/pubblicazione + snapshot versione. IDEMPOTENTE.
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("CLI only\n"); }
|
||||
require_once __DIR__ . '/../config/env.php';
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
$pdo = Database::getInstance();
|
||||
function ex(PDO $p,string $s,array $a=[]):void{$st=$p->prepare($s);$st->execute($a);}
|
||||
function one(PDO $p,string $s,array $a=[]){$st=$p->prepare($s);$st->execute($a);return $st->fetch(PDO::FETCH_ASSOC);}
|
||||
function all(PDO $p,string $s,array $a=[]){$st=$p->prepare($s);$st->execute($a);return $st->fetchAll(PDO::FETCH_ASSOC);}
|
||||
$NOW='2026-06-20 13:30:00'; $TODAY='2026-06-20'; $NEXT='2027-06-20'; $ORGID=996003; $RSGSI=326; $PRES=103;
|
||||
$m=one($pdo,"SELECT id FROM isms_models WHERE organization_id=?",[$ORGID]); $modelId=(int)$m['id'];
|
||||
|
||||
$docs=all($pdo,"SELECT id,version,status,body_html FROM isms_documents WHERE isms_model_id=? AND status='approved'",[$modelId]);
|
||||
$pub=0;
|
||||
foreach($docs as $d){
|
||||
$v=$d['version']?:'1.0';
|
||||
// snapshot pubblicazione (evita doppioni)
|
||||
$exsnap=one($pdo,"SELECT id FROM isms_document_versions WHERE document_id=? AND version=? AND status='published'",[$d['id'],$v]);
|
||||
if(!$exsnap){
|
||||
ex($pdo,"INSERT INTO isms_document_versions (document_id,isms_model_id,organization_id,version,status,body_html,change_note,created_by,created_at)
|
||||
VALUES (?,?,?,?, 'published', ?, ?, ?, ?)",
|
||||
[$d['id'],$modelId,$ORGID,$v,$d['body_html'],'Pubblicazione versione '.$v.' (set documentale iniziale)',$PRES,$NOW]);
|
||||
}
|
||||
ex($pdo,"UPDATE isms_documents SET status='published',
|
||||
approved_by=?, approved_at=?, reviewed_by=?, reviewed_at=?,
|
||||
published_by=?, published_at=?, effective_date=?, next_review_date=?, updated_at=?
|
||||
WHERE id=?",
|
||||
[$RSGSI,$NOW,$RSGSI,$NOW,$PRES,$NOW,$TODAY,$NEXT,$NOW,$d['id']]);
|
||||
$pub++;
|
||||
}
|
||||
$tot=one($pdo,"SELECT COUNT(*) c FROM isms_documents WHERE isms_model_id=? AND status='published'",[$modelId])['c'];
|
||||
echo "Pubblicati ora: $pub. Totale documenti PUBBLICATI: $tot / ".one($pdo,"SELECT COUNT(*) c FROM isms_documents WHERE isms_model_id=?",[$modelId])['c']."\n";
|
||||
echo "FASE 6 OK\n";
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/**
|
||||
* migrate_060_isms_doc_lifecycle.php — Ciclo di vita documentale ISMS (ISO 27001 cl.7.5).
|
||||
* Estende isms_documents (stati pubblicato/archiviato + tracciamento + riesame) + tabella versioni.
|
||||
* IDEMPOTENTE: ALTER guardati da information_schema, CREATE TABLE IF NOT EXISTS.
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("CLI only\n"); }
|
||||
require_once __DIR__ . '/../config/env.php';
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
$pdo = Database::getInstance();
|
||||
$db = $pdo->query('SELECT DATABASE()')->fetchColumn();
|
||||
function col_exists(PDO $p,string $db,string $t,string $c):bool{
|
||||
$s=$p->prepare("SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND COLUMN_NAME=?");
|
||||
$s->execute([$db,$t,$c]); return (bool)$s->fetchColumn();
|
||||
}
|
||||
function add_col(PDO $p,string $db,string $t,string $c,string $ddl):void{
|
||||
if(col_exists($p,$db,$t,$c)){ echo " = $t.$c\n"; return; }
|
||||
$p->exec("ALTER TABLE `$t` ADD COLUMN $ddl"); echo " + $t.$c\n";
|
||||
}
|
||||
|
||||
echo "Migrazione 060 — ciclo di vita documenti ISMS (db=$db)\n";
|
||||
|
||||
/* 1) Estensione enum stato (idempotente: MODIFY) */
|
||||
$pdo->exec("ALTER TABLE isms_documents MODIFY COLUMN status ENUM('draft','review','approved','published','archived') NOT NULL DEFAULT 'draft'");
|
||||
echo " enum status -> draft/review/approved/published/archived\n";
|
||||
|
||||
/* 2) Colonne di tracciamento del ciclo di vita */
|
||||
add_col($pdo,$db,'isms_documents','reviewed_by', "reviewed_by INT NULL");
|
||||
add_col($pdo,$db,'isms_documents','reviewed_at', "reviewed_at DATETIME NULL");
|
||||
add_col($pdo,$db,'isms_documents','approved_by', "approved_by INT NULL");
|
||||
add_col($pdo,$db,'isms_documents','approved_at', "approved_at DATETIME NULL");
|
||||
add_col($pdo,$db,'isms_documents','published_by', "published_by INT NULL");
|
||||
add_col($pdo,$db,'isms_documents','published_at', "published_at DATETIME NULL");
|
||||
add_col($pdo,$db,'isms_documents','archived_at', "archived_at DATETIME NULL");
|
||||
add_col($pdo,$db,'isms_documents','review_note', "review_note VARCHAR(500) NULL");
|
||||
add_col($pdo,$db,'isms_documents','effective_date',"effective_date DATE NULL");
|
||||
add_col($pdo,$db,'isms_documents','next_review_date',"next_review_date DATE NULL");
|
||||
|
||||
/* 3) Tabella storico versioni (snapshot ad ogni approvazione/pubblicazione) */
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS isms_document_versions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
document_id INT NOT NULL,
|
||||
isms_model_id INT NOT NULL,
|
||||
organization_id INT NOT NULL,
|
||||
version VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
body_html LONGTEXT NULL,
|
||||
change_note VARCHAR(255) NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME NULL,
|
||||
INDEX idx_docver_doc (document_id),
|
||||
INDEX idx_docver_org (organization_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
echo " tabella isms_document_versions OK\n";
|
||||
|
||||
echo "FATTO. Prossima mig=061.\n";
|
||||
@@ -287,8 +287,13 @@ class IsmsModelController extends BaseController
|
||||
$this->requireOrgAccess();
|
||||
$model = $this->requireModel();
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT id, doc_type, title, status, ai_generated, version, updated_at
|
||||
FROM isms_documents WHERE isms_model_id = ? ORDER BY updated_at DESC",
|
||||
"SELECT d.id, d.doc_type, d.title, d.status, d.ai_generated, d.version, d.updated_at,
|
||||
d.approved_at, d.published_at, d.next_review_date, d.effective_date, d.review_note,
|
||||
ua.full_name AS approved_by_name, up.full_name AS published_by_name
|
||||
FROM isms_documents d
|
||||
LEFT JOIN users ua ON ua.id = d.approved_by
|
||||
LEFT JOIN users up ON up.id = d.published_by
|
||||
WHERE d.isms_model_id = ? ORDER BY d.id",
|
||||
[$model['id']]
|
||||
);
|
||||
$this->jsonSuccess(['documents' => $rows]);
|
||||
@@ -338,6 +343,169 @@ class IsmsModelController extends BaseController
|
||||
$this->jsonSuccess(['id' => $id], 'Documento aggiornato');
|
||||
}
|
||||
|
||||
/* ───────── Ciclo di vita documentale (ISO 27001 cl.7.5) ───────── */
|
||||
|
||||
private function requireDocument(int $id): array
|
||||
{
|
||||
$model = $this->requireModel();
|
||||
$doc = Database::fetchOne('SELECT * FROM isms_documents WHERE id = ? AND isms_model_id = ?', [$id, $model['id']]);
|
||||
if (!$doc) { $this->jsonError('Documento non trovato', 404, 'NOT_FOUND'); }
|
||||
return $doc;
|
||||
}
|
||||
|
||||
private function bumpVersion(string $v, bool $major = false): string
|
||||
{
|
||||
$parts = explode('.', preg_replace('/[^0-9.]/', '', $v ?: '1.0'));
|
||||
$maj = (int) ($parts[0] ?? 1); $min = (int) ($parts[1] ?? 0);
|
||||
if ($major) { $maj++; $min = 0; } else { $min++; }
|
||||
return $maj . '.' . $min;
|
||||
}
|
||||
|
||||
private function snapshotDocument(array $doc, string $changeNote): void
|
||||
{
|
||||
Database::insert('isms_document_versions', [
|
||||
'document_id' => $doc['id'],
|
||||
'isms_model_id' => $doc['isms_model_id'],
|
||||
'organization_id' => $doc['organization_id'],
|
||||
'version' => $doc['version'] ?? '1.0',
|
||||
'status' => $doc['status'],
|
||||
'body_html' => $doc['body_html'] ?? null,
|
||||
'change_note' => $changeNote,
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents/{id}/submit bozza -> in revisione */
|
||||
public function submitDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
$doc = $this->requireDocument($id);
|
||||
if ($doc['status'] !== 'draft') { $this->jsonError('Solo una bozza può essere inviata in revisione', 409, 'BAD_STATE'); }
|
||||
Database::update('isms_documents', ['status' => 'review', 'review_note' => null, 'updated_at' => date('Y-m-d H:i:s')], 'id = ?', [$id]);
|
||||
$this->logAudit('isms_document_submitted', 'isms_document', $id, []);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'review'], 'Documento inviato in revisione');
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents/{id}/approve in revisione -> approvato */
|
||||
public function approveDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
$doc = $this->requireDocument($id);
|
||||
if (!in_array($doc['status'], ['review', 'draft'], true)) { $this->jsonError('Stato non valido per l\'approvazione', 409, 'BAD_STATE'); }
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Database::update('isms_documents', [
|
||||
'status' => 'approved', 'approved_by' => $this->getCurrentUserId(), 'approved_at' => $now,
|
||||
'reviewed_by' => $this->getCurrentUserId(), 'reviewed_at' => $now, 'review_note' => null, 'updated_at' => $now,
|
||||
], 'id = ?', [$id]);
|
||||
$this->logAudit('isms_document_approved', 'isms_document', $id, ['version' => $doc['version']]);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'approved'], 'Documento approvato');
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents/{id}/publish approvato -> pubblicato (in vigore) */
|
||||
public function publishDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
$doc = $this->requireDocument($id);
|
||||
if ($doc['status'] !== 'approved') { $this->jsonError('Solo un documento approvato può essere pubblicato', 409, 'BAD_STATE'); }
|
||||
$now = date('Y-m-d H:i:s'); $today = date('Y-m-d'); $nextReview = date('Y-m-d', strtotime('+1 year'));
|
||||
$this->snapshotDocument(array_merge($doc, ['status' => 'published']), 'Pubblicazione versione ' . ($doc['version'] ?? '1.0'));
|
||||
Database::update('isms_documents', [
|
||||
'status' => 'published', 'published_by' => $this->getCurrentUserId(), 'published_at' => $now,
|
||||
'effective_date' => $today, 'next_review_date' => $nextReview, 'updated_at' => $now,
|
||||
], 'id = ?', [$id]);
|
||||
$this->logAudit('isms_document_published', 'isms_document', $id, ['version' => $doc['version']]);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'published'], 'Documento pubblicato (in vigore)');
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents/{id}/reject in revisione -> bozza (con nota) */
|
||||
public function rejectDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
$doc = $this->requireDocument($id);
|
||||
if ($doc['status'] !== 'review') { $this->jsonError('Solo un documento in revisione può essere rimandato in bozza', 409, 'BAD_STATE'); }
|
||||
$note = trim((string) ($this->getJsonBody()['note'] ?? ''));
|
||||
Database::update('isms_documents', ['status' => 'draft', 'review_note' => ($note !== '' ? $note : 'Rimandato in bozza dal revisore'), 'updated_at' => date('Y-m-d H:i:s')], 'id = ?', [$id]);
|
||||
$this->logAudit('isms_document_rejected', 'isms_document', $id, ['note' => $note]);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'draft'], 'Documento rimandato in bozza');
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents/{id}/archive pubblicato -> archiviato */
|
||||
public function archiveDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
$doc = $this->requireDocument($id);
|
||||
if ($doc['status'] !== 'published') { $this->jsonError('Solo un documento pubblicato può essere archiviato', 409, 'BAD_STATE'); }
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Database::update('isms_documents', ['status' => 'archived', 'archived_at' => $now, 'updated_at' => $now], 'id = ?', [$id]);
|
||||
$this->logAudit('isms_document_archived', 'isms_document', $id, []);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'archived'], 'Documento archiviato');
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents/{id}/newVersion pubblicato/archiviato -> nuova bozza */
|
||||
public function newVersionDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
$doc = $this->requireDocument($id);
|
||||
if (!in_array($doc['status'], ['published', 'archived', 'approved'], true)) { $this->jsonError('Nuova versione possibile solo da documento approvato/pubblicato/archiviato', 409, 'BAD_STATE'); }
|
||||
$major = !empty($this->getJsonBody()['major']);
|
||||
$newV = $this->bumpVersion($doc['version'] ?? '1.0', $major);
|
||||
$this->snapshotDocument($doc, 'Apertura nuova versione ' . $newV . ' (dalla ' . ($doc['version'] ?? '1.0') . ')');
|
||||
Database::update('isms_documents', [
|
||||
'status' => 'draft', 'version' => $newV,
|
||||
'approved_by' => null, 'approved_at' => null, 'reviewed_by' => null, 'reviewed_at' => null,
|
||||
'published_by' => null, 'published_at' => null, 'archived_at' => null, 'review_note' => null, 'updated_at' => date('Y-m-d H:i:s'),
|
||||
], 'id = ?', [$id]);
|
||||
$this->logAudit('isms_document_new_version', 'isms_document', $id, ['version' => $newV]);
|
||||
$this->jsonSuccess(['id' => $id, 'status' => 'draft', 'version' => $newV], 'Nuova versione ' . $newV . ' in bozza');
|
||||
}
|
||||
|
||||
/** GET /api/isms/documents/{id}/versions */
|
||||
public function documentVersions(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$this->requireDocument($id);
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT v.id, v.version, v.status, v.change_note, v.created_at, u.full_name AS by_name
|
||||
FROM isms_document_versions v LEFT JOIN users u ON u.id = v.created_by
|
||||
WHERE v.document_id = ? ORDER BY v.id DESC', [$id]);
|
||||
$this->jsonSuccess(['versions' => $rows]);
|
||||
}
|
||||
|
||||
/** GET /api/isms/documents/{id}/word — scarica .doc (Word, modificabile) */
|
||||
public function exportDocumentWord(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$doc = $this->requireDocument($id);
|
||||
$org = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [$doc['organization_id']]);
|
||||
$orgName = htmlspecialchars((string) ($org['name'] ?? ''));
|
||||
$statusLabel = [
|
||||
'draft' => 'Bozza', 'review' => 'In revisione', 'approved' => 'Approvato',
|
||||
'published' => 'Pubblicato', 'archived' => 'Archiviato',
|
||||
][$doc['status']] ?? $doc['status'];
|
||||
$meta = $orgName . ' — Versione ' . htmlspecialchars((string) ($doc['version'] ?? '1.0')) . ' — Stato: ' . $statusLabel;
|
||||
if (!empty($doc['approved_at'])) $meta .= ' — Approvato il ' . date('d/m/Y', strtotime($doc['approved_at']));
|
||||
if (!empty($doc['published_at'])) $meta .= ' — In vigore dal ' . date('d/m/Y', strtotime($doc['published_at']));
|
||||
if (!empty($doc['next_review_date'])) $meta .= ' — Prossimo riesame: ' . date('d/m/Y', strtotime($doc['next_review_date']));
|
||||
|
||||
$title = htmlspecialchars((string) $doc['title']);
|
||||
$body = $doc['body_html'] ?? '';
|
||||
$html = "<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>"
|
||||
. "<head><meta charset='utf-8'><title>$title</title>"
|
||||
. "<style>body{font-family:Calibri,Arial,sans-serif;font-size:11pt;color:#1b1b1b;line-height:1.4;}"
|
||||
. "h1{font-size:18pt;color:#0066CC;}h2{font-size:14pt;color:#0066CC;}h3{font-size:12pt;color:#17324d;}"
|
||||
. "table{border-collapse:collapse;width:100%;}td,th{border:1px solid #999;padding:5px;font-size:10pt;}th{background:#eef4fb;}"
|
||||
. ".meta{color:#555;font-size:9pt;border-bottom:1px solid #ccc;padding-bottom:6px;margin-bottom:12px;}</style></head><body>"
|
||||
. "<h1>$title</h1><p class='meta'>$meta</p>$body</body></html>";
|
||||
|
||||
$slug = trim(preg_replace('/[^A-Za-z0-9]+/', '_', (string) $doc['title']), '_');
|
||||
$fname = $slug . '_v' . ($doc['version'] ?? '1.0') . '.doc';
|
||||
header('Content-Type: application/msword; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $fname . '"');
|
||||
echo $html;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/isms/documents/ai-generate
|
||||
* Body: { doc_type, title? } - genera una bozza con AI (grounding fonti certe).
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 060_isms_document_lifecycle.sql — Ciclo di vita documentale ISMS (ISO/IEC 27001 cl. 7.5)
|
||||
-- Record DDL. Apply autoritativo idempotente: application/cli/migrate_060_isms_doc_lifecycle.php
|
||||
-- (ALTER guardati da information_schema; CREATE TABLE IF NOT EXISTS).
|
||||
|
||||
-- Stati del ciclo di vita: aggiunti 'published' e 'archived'
|
||||
ALTER TABLE isms_documents
|
||||
MODIFY COLUMN status ENUM('draft','review','approved','published','archived') NOT NULL DEFAULT 'draft';
|
||||
|
||||
-- Tracciamento del ciclo di vita
|
||||
ALTER TABLE isms_documents ADD COLUMN reviewed_by INT NULL;
|
||||
ALTER TABLE isms_documents ADD COLUMN reviewed_at DATETIME NULL;
|
||||
ALTER TABLE isms_documents ADD COLUMN approved_by INT NULL;
|
||||
ALTER TABLE isms_documents ADD COLUMN approved_at DATETIME NULL;
|
||||
ALTER TABLE isms_documents ADD COLUMN published_by INT NULL;
|
||||
ALTER TABLE isms_documents ADD COLUMN published_at DATETIME NULL;
|
||||
ALTER TABLE isms_documents ADD COLUMN archived_at DATETIME NULL;
|
||||
ALTER TABLE isms_documents ADD COLUMN review_note VARCHAR(500) NULL;
|
||||
ALTER TABLE isms_documents ADD COLUMN effective_date DATE NULL;
|
||||
ALTER TABLE isms_documents ADD COLUMN next_review_date DATE NULL;
|
||||
|
||||
-- Storico versioni (snapshot ad ogni approvazione/pubblicazione/nuova versione)
|
||||
CREATE TABLE IF NOT EXISTS isms_document_versions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
document_id INT NOT NULL,
|
||||
isms_model_id INT NOT NULL,
|
||||
organization_id INT NOT NULL,
|
||||
version VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
body_html LONGTEXT NULL,
|
||||
change_note VARCHAR(255) NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME NULL,
|
||||
INDEX idx_docver_doc (document_id),
|
||||
INDEX idx_docver_org (organization_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -293,6 +293,14 @@ $actionMap = [
|
||||
'POST:documents' => 'createDocument',
|
||||
'POST:documents/aiGenerate' => 'aiGenerateDocument',
|
||||
'PUT:documents/{subId}' => 'updateDocument',
|
||||
'POST:documents/{subId}/submit' => 'submitDocument', // ciclo di vita: bozza->revisione
|
||||
'POST:documents/{subId}/approve' => 'approveDocument', // revisione->approvato
|
||||
'POST:documents/{subId}/publish' => 'publishDocument', // approvato->pubblicato
|
||||
'POST:documents/{subId}/reject' => 'rejectDocument', // revisione->bozza (con nota)
|
||||
'POST:documents/{subId}/archive' => 'archiveDocument', // pubblicato->archiviato
|
||||
'POST:documents/{subId}/newVersion' => 'newVersionDocument', // pubblicato->nuova bozza
|
||||
'GET:documents/{subId}/versions' => 'documentVersions',
|
||||
'GET:documents/{subId}/word' => 'exportDocumentWord', // download .doc Word
|
||||
'GET:readiness' => 'readiness',
|
||||
'GET:export' => 'export',
|
||||
],
|
||||
|
||||
+2
-2
@@ -197,7 +197,7 @@
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260631"></script>
|
||||
<script src="/js/i18n.js?v=20260631"></script>
|
||||
<script src="/js/help.js?v=20260618p2"></script>
|
||||
<script src="/js/isms.js"></script>
|
||||
<script src="/js/help.js?v=20260620"></script>
|
||||
<script src="/js/isms.js?v=20260620"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -129,6 +129,16 @@ const HelpSystem = (function () {
|
||||
'<strong>6. Monitoraggio e Miglioramento (cl. 9-10)</strong>: audit interni e non conformita si gestiscono nei moduli Audit & Report e NCR/CAPA. Una checklist mostra il completamento del SGSI.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Ciclo di vita dei documenti (cl. 7.5)',
|
||||
items: [
|
||||
'Ogni documento (Manuale, politiche, procedure, istruzioni) ha un <strong>ciclo di vita</strong>: <strong>Bozza → In revisione → Approvato → Pubblicato → Archiviato</strong>, mostrato con un badge colorato nel passo 5 (Documenti).',
|
||||
'<strong>Transizioni</strong>: "Invia in revisione", "Approva" / "Rimanda in bozza", "Pubblica" (mette il documento <strong>in vigore</strong>), "Archivia" (lo ritira), "Nuova versione" (riapre una bozza con numero di versione incrementato).',
|
||||
'Ad ogni pubblicazione viene salvato uno <strong>snapshot di versione</strong>: il pulsante "Storico" mostra tutte le versioni con autore, data e nota. Alla pubblicazione vengono impostate la data di entrata in vigore e la <strong>prossima data di riesame</strong> (a 1 anno).',
|
||||
'<strong>Scarica Word</strong>: il pulsante "Word" esporta il documento in formato .doc <strong>modificabile</strong> (mantiene titoli, elenchi e tabelle); "Apri" lo mostra in una nuova scheda.',
|
||||
'E\' la gestione delle <strong>informazioni documentate</strong> richiesta dalla ISO/IEC 27001 <strong>cl. 7.5</strong>: creazione, approvazione, controllo delle versioni e della distribuzione.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'SoA pre-popolato dal NIS2',
|
||||
items: [
|
||||
|
||||
+97
-5
@@ -219,19 +219,111 @@ function showSoaStats(s){
|
||||
if(e) e.textContent = (lang()==='en'?'Applicable: ':'Applicabili: ')+s.applicable+' · '+(lang()==='en'?'avg impl.: ':'impl. media: ')+s.avg_implementation_pct+'%';
|
||||
}
|
||||
|
||||
// ── Documenti (cl.7-8) ──
|
||||
// ── Documenti (cl.7-8) — ciclo di vita ISO 27001 cl.7.5 ──
|
||||
const DOC_STATUS = {
|
||||
draft: { it:'Bozza', en:'Draft', color:'#6b7280' },
|
||||
review: { it:'In revisione', en:'In review', color:'#f59e0b' },
|
||||
approved: { it:'Approvato', en:'Approved', color:'#3b82f6' },
|
||||
published:{ it:'Pubblicato', en:'Published', color:'#16a34a' },
|
||||
archived: { it:'Archiviato', en:'Archived', color:'#475569' }
|
||||
};
|
||||
function fmtDate(d){ if(!d) return ''; try{ return new Date((''+d).replace(' ','T')).toLocaleDateString(lang()==='en'?'en-GB':'it-IT'); }catch(e){ return esc(d); } }
|
||||
function docBtn(label, onclick, bg){
|
||||
return '<button type="button" onclick="'+onclick+'" style="font-size:.78rem;padding:3px 10px;border-radius:6px;border:1px solid '+(bg||'#0066CC')+';background:'+(bg||'#fff')+';color:'+(bg?'#fff':'#0066CC')+';cursor:pointer;">'+label+'</button>';
|
||||
}
|
||||
|
||||
async function loadDocs(){
|
||||
try {
|
||||
const res = await api.ismsDocuments();
|
||||
const docs = res.documents||[]; const isEn = lang()==='en';
|
||||
if(!docs.length){ el('docs-list').innerHTML = '<p class="text-muted">'+(isEn?'No documents yet.':'Nessun documento.')+'</p>'; return; }
|
||||
let html='';
|
||||
(res.documents||[]).forEach(function(d){
|
||||
docs.forEach(function(d){
|
||||
const st = DOC_STATUS[d.status] || { it:d.status, en:d.status, color:'#6b7280' };
|
||||
const ai = (+d.ai_generated)?' <span class="soa-derived">AI</span>':'';
|
||||
html += '<div class="ck-item"><div><strong>'+esc(d.title)+'</strong> '+ai+' <span class="text-muted" style="font-size:.8rem;">('+esc(d.doc_type)+')</span></div>'+
|
||||
'<span class="badge badge-'+(d.status==='approved'?'success':'warning')+'">'+esc(d.status)+'</span></div>';
|
||||
let meta = (isEn?'Version ':'Versione ')+esc(d.version||'1.0');
|
||||
if(d.approved_at) meta += ' · '+(isEn?'approved ':'approvato il ')+fmtDate(d.approved_at)+(d.approved_by_name?' ('+esc(d.approved_by_name)+')':'');
|
||||
if(d.published_at) meta += ' · '+(isEn?'in force from ':'in vigore dal ')+fmtDate(d.published_at);
|
||||
if(d.next_review_date) meta += ' · '+(isEn?'next review ':'prossimo riesame ')+fmtDate(d.next_review_date);
|
||||
let btns='';
|
||||
if(d.status==='draft'){ btns += docBtn(isEn?'Submit for review':'Invia in revisione','docAction('+d.id+',\'submit\')','#3b82f6'); }
|
||||
else if(d.status==='review'){ btns += docBtn(isEn?'Approve':'Approva','docAction('+d.id+',\'approve\')','#16a34a'); btns += docBtn(isEn?'Send back':'Rimanda in bozza','docReject('+d.id+')','#ef4444'); }
|
||||
else if(d.status==='approved'){ btns += docBtn(isEn?'Publish':'Pubblica','docAction('+d.id+',\'publish\')','#16a34a'); }
|
||||
else if(d.status==='published'){ btns += docBtn(isEn?'New version':'Nuova versione','docNewVersion('+d.id+')'); btns += docBtn(isEn?'Archive':'Archivia','docAction('+d.id+',\'archive\')','#475569'); }
|
||||
else if(d.status==='archived'){ btns += docBtn(isEn?'New version':'Nuova versione','docNewVersion('+d.id+')'); }
|
||||
btns += docBtn(isEn?'Open':'Apri','docView('+d.id+')');
|
||||
btns += docBtn('Word','docWord('+d.id+')');
|
||||
btns += docBtn(isEn?'History':'Storico','docVersions('+d.id+')');
|
||||
const note = (d.review_note && d.status==='draft') ? ' · <span style="color:#b91c1c;">'+esc(d.review_note)+'</span>' : '';
|
||||
html += '<div class="ck-item" style="display:block;padding:10px 12px;">'+
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;">'+
|
||||
'<div><strong>'+esc(d.title)+'</strong>'+ai+' <span class="text-muted" style="font-size:.8rem;">('+esc(d.doc_type)+')</span></div>'+
|
||||
'<span style="background:'+st.color+';color:#fff;padding:2px 10px;border-radius:10px;font-size:.74rem;white-space:nowrap;">'+(isEn?st.en:st.it)+'</span>'+
|
||||
'</div>'+
|
||||
'<div class="text-muted" style="font-size:.78rem;margin:4px 0 8px;">'+meta+note+'</div>'+
|
||||
'<div style="display:flex;flex-wrap:wrap;gap:6px;">'+btns+'</div>'+
|
||||
'<div id="docver-'+d.id+'" style="margin-top:8px;"></div>'+
|
||||
'</div>';
|
||||
});
|
||||
el('docs-list').innerHTML = html || '<p class="text-muted">'+(lang()==='en'?'No documents yet.':'Nessun documento.')+'</p>';
|
||||
el('docs-list').innerHTML = html;
|
||||
} catch(e){ el('docs-list').innerHTML='<p class="text-muted">'+esc((e&&e.message)||'')+'</p>'; }
|
||||
}
|
||||
|
||||
async function docAction(id, action){
|
||||
const msg = { submit:(lang()==='en'?'Submit for review?':'Inviare in revisione?'),
|
||||
approve:(lang()==='en'?'Approve this document?':'Approvare il documento?'),
|
||||
publish:(lang()==='en'?'Publish (put in force)?':'Pubblicare il documento (metterlo in vigore)?'),
|
||||
archive:(lang()==='en'?'Archive this document?':'Archiviare il documento?') }[action] || 'OK?';
|
||||
if(!confirm(msg)) return;
|
||||
const r = await api.post('/isms/documents/'+id+'/'+action);
|
||||
if(r && r.success){ loadDocs(); } else { alert((r&&r.message)||(lang()==='en'?'Action failed':'Operazione non riuscita')); }
|
||||
}
|
||||
async function docReject(id){
|
||||
const note = prompt(lang()==='en'?'Reason for sending back to draft:':'Motivo del rinvio in bozza:');
|
||||
if(note===null) return;
|
||||
const r = await api.post('/isms/documents/'+id+'/reject', { note:note });
|
||||
if(r && r.success){ loadDocs(); } else { alert((r&&r.message)||'Errore'); }
|
||||
}
|
||||
async function docNewVersion(id){
|
||||
if(!confirm(lang()==='en'?'Create a new editable draft version?':'Creare una nuova versione modificabile (bozza)?')) return;
|
||||
const r = await api.post('/isms/documents/'+id+'/newVersion', { major:false });
|
||||
if(r && r.success){ loadDocs(); } else { alert((r&&r.message)||'Errore'); }
|
||||
}
|
||||
async function docVersions(id){
|
||||
const box = el('docver-'+id); if(!box) return;
|
||||
if(box.innerHTML){ box.innerHTML=''; return; }
|
||||
box.innerHTML = '<span class="text-muted" style="font-size:.8rem;">…</span>';
|
||||
const r = await api.get('/isms/documents/'+id+'/versions');
|
||||
const vs = (r && r.data && r.data.versions) || [];
|
||||
if(!vs.length){ box.innerHTML = '<span class="text-muted" style="font-size:.8rem;">'+(lang()==='en'?'No version history.':'Nessuna versione storicizzata.')+'</span>'; return; }
|
||||
let h = '<table style="width:100%;font-size:.78rem;border-collapse:collapse;margin-top:4px;"><tr>'+
|
||||
'<th style="text-align:left;border-bottom:1px solid #ddd;padding:3px;">Vers.</th>'+
|
||||
'<th style="text-align:left;border-bottom:1px solid #ddd;padding:3px;">'+(lang()==='en'?'Status':'Stato')+'</th>'+
|
||||
'<th style="text-align:left;border-bottom:1px solid #ddd;padding:3px;">'+(lang()==='en'?'Note':'Nota')+'</th>'+
|
||||
'<th style="text-align:left;border-bottom:1px solid #ddd;padding:3px;">'+(lang()==='en'?'By / date':'Da / data')+'</th></tr>';
|
||||
vs.forEach(function(v){ h += '<tr><td style="padding:3px;">'+esc(v.version)+'</td><td style="padding:3px;">'+esc(v.status)+'</td><td style="padding:3px;">'+esc(v.change_note||'')+'</td><td style="padding:3px;">'+esc(v.by_name||'')+' · '+fmtDate(v.created_at)+'</td></tr>'; });
|
||||
box.innerHTML = h + '</table>';
|
||||
}
|
||||
async function docWord(id){
|
||||
try{
|
||||
const headers = { 'Authorization':'Bearer '+api.token }; if(api.orgId) headers['X-Organization-Id']=api.orgId;
|
||||
const resp = await fetch(api.baseUrl+'/isms/documents/'+id+'/word', { headers });
|
||||
if(!resp.ok) throw new Error((lang()==='en'?'Download failed ':'Download non riuscito ')+'('+resp.status+')');
|
||||
let fname = 'documento_'+id+'.doc';
|
||||
const cd = resp.headers.get('Content-Disposition')||''; const mm = cd.match(/filename="?([^"]+)"?/); if(mm) fname = mm[1];
|
||||
const blob = await resp.blob(); const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href=url; a.download=fname; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
||||
}catch(e){ alert((e&&e.message)||'Errore download'); }
|
||||
}
|
||||
async function docView(id){
|
||||
try{
|
||||
const headers = { 'Authorization':'Bearer '+api.token }; if(api.orgId) headers['X-Organization-Id']=api.orgId;
|
||||
const resp = await fetch(api.baseUrl+'/isms/documents/'+id+'/word', { headers });
|
||||
if(!resp.ok) throw new Error('Apertura non riuscita'); const txt = await resp.text();
|
||||
const w = window.open('','_blank'); if(!w){ alert(lang()==='en'?'Enable popups to view':'Abilita i popup per visualizzare'); return; }
|
||||
w.document.write(txt); w.document.close();
|
||||
}catch(e){ alert((e&&e.message)||'Errore'); }
|
||||
}
|
||||
async function aiGenDoc(){
|
||||
const btn = el('btn-aigen'); btn.disabled=true;
|
||||
hint('aigen-hint', lang()==='en'?'Generating draft…':'Generazione bozza in corso…');
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version": "1.23.10", "build": "2026-06-19-v1.23.10", "date": "2026-06-19", "changelog": "UI V2 (Bootstrap Italia / AGID) completata su tutte le pagine: migrate architecture.html e workflow.html (CSS Bootstrap Italia + bundle JS + common-bi.js per la sidebar V2 coerente con il resto dell'app) e i documenti integrazioniext.html e mktg-api-doc.html (CSS Bootstrap Italia, design system style.css resta autorevole). Rimossa la pagina morta setup-org.html (sostituita da onboarding.html). Il funnel login/register/onboarding era gia' su Bootstrap Italia. Nessuna modifica di funzionalita', nessuna migrazione DB."}
|
||||
{"version": "1.24.0", "build": "2026-06-20-v1.24.0", "date": "2026-06-20", "changelog": "Gestione documentale ISMS / ciclo di vita ISO 27001 cl.7.5. Ogni documento del Modello SGSI ha ora un ciclo di vita completo: Bozza -> In revisione -> Approvato -> Pubblicato -> Archiviato, con tracciamento di revisore/approvatore/pubblicatore e date, data di entrata in vigore e prossima data di riesame. Storico versioni (snapshot ad ogni approvazione/pubblicazione, consultabile). Download Word (.doc) modificabile per ogni documento. Migrazione 060 (estensione stati + colonne tracciamento + tabella isms_document_versions). 8 nuovi endpoint /api/isms/documents/{id}/(submit|approve|publish|reject|archive|newVersion|versions|word). UI nello step Documenti con badge di stato, pulsanti di transizione, download e storico."}
|
||||
|
||||
Reference in New Issue
Block a user