diff --git a/application/controllers/IsmsModelController.php b/application/controllers/IsmsModelController.php index ebed7f3..3bcf051 100644 --- a/application/controllers/IsmsModelController.php +++ b/application/controllers/IsmsModelController.php @@ -323,13 +323,16 @@ class IsmsModelController extends BaseController /** PUT /api/isms/documents/{id} */ public function updateDocument(int $id): void { - $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); $model = $this->requireModel(); $body = $this->getJsonBody(); - $row = Database::fetchOne('SELECT id FROM isms_documents WHERE id = ? AND isms_model_id = ?', [$id, $model['id']]); + $row = Database::fetchOne('SELECT id, status FROM isms_documents WHERE id = ? AND isms_model_id = ?', [$id, $model['id']]); if (!$row) { $this->jsonError('Documento non trovato', 404, 'NOT_FOUND'); } + if (array_key_exists('body_html', $body) && !in_array($row['status'], ['draft', 'review'], true)) { + $this->jsonError('Il contenuto è modificabile solo in stato Bozza o In revisione. Crea una nuova versione per modificare un documento pubblicato.', 409, 'NOT_EDITABLE'); + } $fields = []; foreach (['title','body_html'] as $k) { if (array_key_exists($k, $body)) $fields[$k] = (string) $body[$k]; @@ -506,6 +509,60 @@ class IsmsModelController extends BaseController exit; } + /** GET /api/isms/documents/{id} — documento completo (per visualizzazione/editor) */ + public function getDocument(int $id): void + { + $this->requireOrgAccess(); + $doc = $this->requireDocument($id); + $this->jsonSuccess(['document' => $doc]); + } + + /** GET /api/isms/documentsWordAll — scarica l'INTERO set documentale in un unico .doc (Word) */ + public function exportAllDocumentsWord(): void + { + $this->requireOrgAccess(); + $model = $this->requireModel(); + $org = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]); + $orgName = htmlspecialchars((string) ($org['name'] ?? '')); + $docs = Database::fetchAll( + "SELECT title, doc_type, status, version, body_html, published_at + FROM isms_documents WHERE isms_model_id = ? AND status <> 'archived' ORDER BY id", + [$model['id']]); + $statusLabel = ['draft' => 'Bozza', 'review' => 'In revisione', 'approved' => 'Approvato', 'published' => 'Pubblicato', 'archived' => 'Archiviato']; + $pageBreak = "
"; + + $cover = "

Sistema di Gestione per la Sicurezza delle Informazioni

" + . "

$orgName

" + . "

Raccolta documentale — generata il " . date('d/m/Y') . " — " . count($docs) . " documenti

"; + $toc = "

Indice

    "; + foreach ($docs as $d) { + $toc .= "
  1. " . htmlspecialchars((string) $d['title']) . " (v" . htmlspecialchars((string) ($d['version'] ?? '1.0')) . ")
  2. "; + } + $toc .= "
"; + + $bodyAll = ''; + foreach ($docs as $d) { + $sl = $statusLabel[$d['status']] ?? $d['status']; + $m = $orgName . ' — v' . htmlspecialchars((string) ($d['version'] ?? '1.0')) . ' — ' . $sl; + if (!empty($d['published_at'])) $m .= ' — in vigore dal ' . date('d/m/Y', strtotime($d['published_at'])); + $bodyAll .= $pageBreak . "

" . htmlspecialchars((string) $d['title']) . "

$m

" . ($d['body_html'] ?? ''); + } + + $html = "" + . "SGSI - $orgName" + . "" + . $cover . $toc . $bodyAll . ""; + + $slug = trim(preg_replace('/[^A-Za-z0-9]+/', '_', (string) ($org['name'] ?? 'SGSI')), '_'); + header('Content-Type: application/msword; charset=utf-8'); + header('Content-Disposition: attachment; filename="SGSI_' . $slug . '_completo.doc"'); + echo $html; + exit; + } + /** * POST /api/isms/documents/ai-generate * Body: { doc_type, title? } - genera una bozza con AI (grounding fonti certe). diff --git a/public/index.php b/public/index.php index 0f89e82..7cfdb89 100644 --- a/public/index.php +++ b/public/index.php @@ -301,6 +301,8 @@ $actionMap = [ 'POST:documents/{subId}/newVersion' => 'newVersionDocument', // pubblicato->nuova bozza 'GET:documents/{subId}/versions' => 'documentVersions', 'GET:documents/{subId}/word' => 'exportDocumentWord', // download .doc Word + 'GET:documentsWordAll' => 'exportAllDocumentsWord', // download UNICO di tutto il set + 'GET:documents/{subId}' => 'getDocument', // documento completo (editor) — {subId} numerico 'GET:readiness' => 'readiness', 'GET:export' => 'export', ], diff --git a/public/isms.html b/public/isms.html index 2ab39c6..0c0571b 100644 --- a/public/isms.html +++ b/public/isms.html @@ -197,7 +197,7 @@ - - + + diff --git a/public/js/help.js b/public/js/help.js index db5d920..18f653a 100644 --- a/public/js/help.js +++ b/public/js/help.js @@ -135,7 +135,8 @@ const HelpSystem = (function () { 'Ogni documento (Manuale, politiche, procedure, istruzioni) ha un ciclo di vita: Bozza → In revisione → Approvato → Pubblicato → Archiviato, mostrato con un badge colorato nel passo 5 (Documenti).', 'Transizioni: "Invia in revisione", "Approva" / "Rimanda in bozza", "Pubblica" (mette il documento in vigore), "Archivia" (lo ritira), "Nuova versione" (riapre una bozza con numero di versione incrementato).', 'Ad ogni pubblicazione viene salvato uno snapshot di versione: il pulsante "Storico" mostra tutte le versioni con autore, data e nota. Alla pubblicazione vengono impostate la data di entrata in vigore e la prossima data di riesame (a 1 anno).', - 'Scarica Word: il pulsante "Word" esporta il documento in formato .doc modificabile (mantiene titoli, elenchi e tabelle); "Apri" lo mostra in una nuova scheda.', + 'Scarica Word: il pulsante "Word" esporta il singolo documento in formato .doc modificabile (mantiene titoli, elenchi e tabelle); "Apri" lo mostra in una nuova scheda. Il pulsante "Scarica tutto (Word)" esporta l\'intero set documentale in un unico file (copertina + indice).', + 'Modifica in-app: per le bozze e i documenti in revisione il pulsante "Modifica" apre un editor con barra strumenti (grassetto, titoli, elenchi) e modalita HTML. I documenti gia pubblicati non si modificano direttamente: usa prima "Nuova versione" per riaprirli in bozza (cosi la versione in vigore resta tracciata).', 'E\' la gestione delle informazioni documentate richiesta dalla ISO/IEC 27001 cl. 7.5: creazione, approvazione, controllo delle versioni e della distribuzione.' ] }, diff --git a/public/js/isms.js b/public/js/isms.js index 9363ca4..46b2f29 100644 --- a/public/js/isms.js +++ b/public/js/isms.js @@ -237,7 +237,7 @@ async function loadDocs(){ const res = await api.ismsDocuments(); const docs = res.documents||[]; const isEn = lang()==='en'; if(!docs.length){ el('docs-list').innerHTML = '

'+(isEn?'No documents yet.':'Nessun documento.')+'

'; return; } - let html=''; + let html = '
'+docBtn(isEn?'Download all (Word)':'Scarica tutto (Word)','docWordAll()','#0066CC')+'
'; docs.forEach(function(d){ const st = DOC_STATUS[d.status] || { it:d.status, en:d.status, color:'#6b7280' }; const ai = (+d.ai_generated)?' AI':''; @@ -251,6 +251,7 @@ async function loadDocs(){ 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+')'); } + if(d.status==='draft'||d.status==='review') btns += docBtn(isEn?'Edit':'Modifica','docEdit('+d.id+')','#7c3aed'); btns += docBtn(isEn?'Open':'Apri','docView('+d.id+')'); btns += docBtn('Word','docWord('+d.id+')'); btns += docBtn(isEn?'History':'Storico','docVersions('+d.id+')'); @@ -335,6 +336,79 @@ async function aiGenDoc(){ finally { btn.disabled=false; } } +// ── Download UNICO dell'intero set ── +async function docWordAll(){ + try{ + const headers = { 'Authorization':'Bearer '+api.token }; if(api.orgId) headers['X-Organization-Id']=api.orgId; + const resp = await fetch(api.baseUrl+'/isms/documentsWordAll', { headers }); + if(!resp.ok) throw new Error((lang()==='en'?'Download failed ':'Download non riuscito ')+'('+resp.status+')'); + let fname='SGSI_completo.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'); } +} + +// ── Editor in-app del contenuto (bozza/revisione) ── +let _docEditId = null; +function _tb(label, onclick, st){ return ''; } +function ensureDocEditor(){ + if(el('doc-editor-overlay')) return; const isEn=lang()==='en'; + const ov=document.createElement('div'); ov.id='doc-editor-overlay'; + ov.style.cssText='display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9999;'; + ov.innerHTML= + '
'+ + '
'+ + ''+(isEn?'Edit document':'Modifica documento')+''+ + ''+ + ''+ + '
'+ + '
'+ + _tb('B','docFmt(\'bold\')','font-weight:700;')+_tb('I','docFmt(\'italic\')','font-style:italic;')+ + _tb('H2','docBlock(\'h2\')','')+_tb('H3','docBlock(\'h3\')','')+_tb('¶','docBlock(\'p\')','')+ + _tb('• '+(isEn?'List':'Elenco'),'docFmt(\'insertUnorderedList\')','')+_tb('1.','docFmt(\'insertOrderedList\')','')+ + ''+_tb('</> HTML','docToggleHtml()','')+ + '
'+ + '
'+ + ''+ + '
'+ + ''+ + ''+ + ''+ + '
'+ + '
'; + document.body.appendChild(ov); +} +function docFmt(cmd){ try{ document.execCommand(cmd,false,null); }catch(e){} const b=el('doc-edit-body'); if(b) b.focus(); } +function docBlock(tag){ try{ document.execCommand('formatBlock',false,tag); }catch(e){} const b=el('doc-edit-body'); if(b) b.focus(); } +function docToggleHtml(){ + const body=el('doc-edit-body'), ta=el('doc-edit-html'); + if(ta.style.display==='none'){ ta.value=body.innerHTML; ta.style.display='block'; body.style.display='none'; } + else { body.innerHTML=ta.value; ta.style.display='none'; body.style.display='block'; } +} +async function docEdit(id){ + ensureDocEditor(); + const r=await api.get('/isms/documents/'+id); const d=r&&r.data&&r.data.document; + if(!d){ alert((r&&r.message)||'Errore'); return; } + _docEditId=id; + el('doc-edit-title').value=d.title||''; + el('doc-edit-body').innerHTML=d.body_html||''; + el('doc-edit-html').value=''; el('doc-edit-html').style.display='none'; el('doc-edit-body').style.display='block'; + const st=DOC_STATUS[d.status]; + el('doc-edit-hint').textContent=(lang()==='en'?'Status: ':'Stato: ')+(st?(lang()==='en'?st.en:st.it):d.status)+' · v'+(d.version||'1.0'); + el('doc-editor-overlay').style.display='block'; +} +function docEditClose(){ const o=el('doc-editor-overlay'); if(o) o.style.display='none'; _docEditId=null; } +async function docEditSave(){ + if(!_docEditId) return; + const ta=el('doc-edit-html'); + const body=(ta.style.display!=='none')?ta.value:el('doc-edit-body').innerHTML; + const title=el('doc-edit-title').value.trim(); + el('doc-edit-hint').textContent=lang()==='en'?'Saving…':'Salvataggio…'; + const r=await api.put('/isms/documents/'+_docEditId, { title:title, body_html:body }); + if(r&&r.success){ docEditClose(); loadDocs(); } else { el('doc-edit-hint').textContent=''; alert((r&&r.message)||'Errore salvataggio'); } +} + // ── Readiness (cl.9-10) ── async function loadReadiness(){ try { diff --git a/public/version.json b/public/version.json index 03c9cbf..1d7fc15 100644 --- a/public/version.json +++ b/public/version.json @@ -1 +1 @@ -{"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."} +{"version": "1.24.1", "build": "2026-06-20-v1.24.1", "date": "2026-06-20", "changelog": "Gestione documentale ISMS — completata con: (1) download UNICO dell'intero set documentale in un solo file Word (.doc) con copertina e indice (endpoint /api/isms/documentsWordAll); (2) editor in-app del contenuto per le bozze e i documenti in revisione (barra strumenti grassetto/titoli/elenchi + modalita HTML), con guard che impedisce la modifica diretta dei documenti pubblicati (creare prima una nuova versione). Endpoint GET /api/isms/documents/{id} per il caricamento del documento nell'editor. Permessi di modifica estesi a board_member."}