Compare commits

..
79 Commits
Author SHA1 Message Date
DevEnv nis2-agileandClaude Opus 4.8 4ef1faf05f [DOCS] Contesto 2026-08-25: fix Documenti Istituzionali (NO_ORG) + Organigramma freeze/MutationObserver
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-26 18:30:43 +02:00
DevEnv nis2-agileandClaude Opus 4.8 396783cd41 [FIX] Documenti Istituzionali (#499): le 5 voci standard non comparivano mai (NO_ORG)
Root cause: InstitutionalDocsController::list() usava requireAuth()+getCurrentOrgId(),
ma currentOrgId e' popolato solo da requireOrgAccess() → getCurrentOrgId() = null →
l'endpoint rispondeva sempre NO_ORG (400) → loadDocs riceveva standards=[] → la card
mostrava solo "Aggiungi voce" (screenshot Simon). L'endpoint NON aveva mai funzionato a runtime.
- Backend: list() usa resolveOrgId() (risolve X-Organization-Id senza lanciare) e restituisce
  SEMPRE le 5 voci standard; i documenti salvati solo se un'org e' selezionata.
- Frontend (dashboard.js): render condiviso + STD_FALLBACK → le 5 voci si mostrano sempre,
  anche se l'API fallisce. Buster dashboard.js 20260825.
- Backend attivato via reload php-fpm host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-26 18:14:07 +02:00
DevEnv nis2-agileandClaude Opus 4.8 69f3894c18 [FIX] RACI + Competenze: stesso anti-loop MutationObserver+createIcons dell'organigramma
Prevenzione proattiva dello stesso freeze latente trovato in organigramma: le pagine
raci.html e competenze.html richiamavano createIcons() su ogni mutazione DOM. Debounce+disconnect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-25 18:15:21 +02:00
DevEnv nis2-agileandClaude Opus 4.8 9eeef784d4 [DOCS] Help online: sezione 'Documenti istituzionali' (5 voci standard + descrizione obbligatoria, #499 follow-up)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-25 18:14:25 +02:00
DevEnv nis2-agileandClaude Opus 4.8 1f9d837c70 [FIX] Organigramma: MutationObserver+createIcons anti-loop (debounce+disconnect)
Secondo possibile innesco del freeze: lo script inline richiamava lucide.createIcons()
su OGNI mutazione DOM (MutationObserver subtree). Poiche' createIcons muta il DOM
(<i>->svg), poteva auto-ritriggerarsi. Ora: debounce 120ms + disconnect durante il
render + flag applying per ignorare le mutazioni auto-prodotte. HTML network-first -> live al reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-25 18:02:09 +02:00
DevEnv nis2-agileandClaude Opus 4.8 bfda75b292 [FIX] Organigramma: guardia anti-ciclo (client+server) — "Nuovo Ruolo" bloccava il sito
Segnalazione Simon: in Struttura interna/Organigramma il clic su "Nuovo Ruolo"
blocca l'intero sito. Causa: né il tree builder server (OrgRoleController::buildTree,
$attach) né il render client (organigramma.js orgNodeHtml) avevano una guardia
anti-ciclo su parent_role_id → con una gerarchia ciclica la ricorsione va in loop
infinito (hang del backend / freeze del render).
- organigramma.js orgNodeHtml(node, seen): salta gli id già visti (rompe il ciclo);
  orgRender passa un Set condiviso. Live via bind-mount (buster 20260806).
- OrgRoleController::buildTree: $attach porta la catena antenati e non ridiscende su
  un id già visto. ⚠️ Richiede reload php-fpm host (opcache) per andare live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-25 18:00:08 +02:00
DevEnv nis2-agileandClaude Opus 4.8 0bf17db50a [DOCS] Piano Guida a percorsi guidati NIS2 (dal metodo TRPG): mapping su asset esistenti + 3 trappole + fasi
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 14:11:52 +02:00
DevEnv nis2-agileandClaude Opus 4.8 cb6106d08c [DOCS] Contesto 2026-08-03: rework Rischi (matrice 4 fasce/toggle, trasversali 12 col) v1.27.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 12:25:50 +02:00
DevEnv nis2-agileandClaude Opus 4.8 f421f4ecdb [DOCS] Rischi rework: help online + manuale + KB (matrice 4 fasce/toggle, trasversali 12 col)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 10:44:58 +02:00
DevEnv nis2-agileandClaude Opus 4.8 4809d8e895 [FEAT] Pagina Rischi (Simon): matrice a 4 fasce + toggle inerente/residuo; trasversali 12 col in fondo
Richieste Simon sulla finestra Rischi:
1) Matrice 5×5: eliminata la fascia 10-14; fasce = 1-2 basso (verde), 3-8 medio
   (arancione), 9-12 alto (rosso tenue), 15-25 critico (rosso). Unificate con le
   bande Alert. (matrixBand + CSS band-low/med/high/crit; rimosse level-1..10).
2) La matrice riepiloga i giudizi INERENTI o RESIDUI con un tasto di selezione
   (setMatrixView; matrixPoints legge likelihood/impact o residual_*).
3) Rischi trasversali:
   3.1 sezione spostata in FONDO (matrice dai requisiti risalita subito dopo la
       tabella derivata);
   3.2 tabella trasversali convertita alle STESSE 12 colonne (inerente+residuo+
       alert+esito), editing inline P/I via api.updateRisk (onTransversalScoreChange);
   3.3 tasti "AI Suggerisci" e "Nuovo Rischio" spostati in testa alla tabella;
   3.4 seconda matrice 5×5 dei trasversali con lo stesso toggle inerente/residuo;
   3.5 tasto Elimina per ogni rischio trasversale (confirmDeleteRisk).
- Solo frontend/statico. version 1.27.0 + SW cache bump.
- Verificato puppeteer (996002): 4 bande, 2 toggle funzionanti, 12 col, 6 delete, 0 errori.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 10:39:05 +02:00
DevEnv nis2-agileandClaude Opus 4.8 b8c7d3c9b9 [DOCS] Contesto 2026-08-01: #501 p3 all-hazard comprimibile + ticket RESOLVED
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 18:22:43 +02:00
DevEnv nis2-agileandClaude Opus 4.8 ed0b16eee2 [FEAT] Rischi #501 p3: sezione all-hazard mantenuta ma "Rischi trasversali" comprimibile
Compromesso approvato (Simon/Cristiano) al posto della cancellazione richiesta al
punto 3 del #501 — validato nis2-expert (all-hazards = obbligo art.21.2 Direttiva
-> art.24 D.Lgs.138/2024; rimuoverla = lacuna di conformità).
- risks.html: la sezione diventa un <details> comprimibile. Header sempre visibile
  con titolo "Rischi trasversali (approccio multirischio / all-hazards) - art. 24
  D.Lgs. 138/2024" + contatore. Apri-se-vuota (updateAllhazardHeader): vuota -> aperta
  (presidiata in audit), con voci -> chiusa (meno ingombro). Nota riformulata che la
  distingue dai "rischi di non conformità".
- i18n.js custom_heading/custom_help (IT+EN) + help.js allineati.
- Manuale + KB RISCHI_MODELLO.md aggiornati. Cache-buster help/i18n 20260731.
- Solo frontend/statico.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 10:49:10 +02:00
DevEnv nis2-agileandClaude Opus 4.8 8736d6bb4f [DOCS] Contesto 2026-07-31: correzioni Simon 12-col LIVE + proposta #501 p3 (validata, no codice)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 08:13:09 +02:00
DevEnv nis2-agileandClaude Opus 4.8 8799620fba [FIX] Rischi 12 col: titoli gruppo "Rischio Inerente/Residuo" + soglie alert 9-12 (feedback Simon)
Riscontro Simon sulla tabella Gestione Rischi:
- Titoli di gruppo sui tripletti Probabilità/Impatto/Valutazione: da
  "VALUTAZIONE RISCHIO INERENTE/RESIDUO" a "Rischio Inerente"/"Rischio Residuo"
  (risks.html thead + i18n.js grp_inherent/grp_residual IT+EN).
- Ritirata eccezione #3: banda Alert riportata a spec Simon 15-25/9-12/3-8/1-2.
  Simon ha ragione: con P,I in 1-5 i prodotti 7/11/13/14 non esistono → nessun
  buco. alertBand resta >=9 (=9-12 in pratica); aggiornati commento, help.js,
  manuale, KB (9-14 -> 9-12).
- Cache-buster help.js/i18n.js -> 20260730b (i18n va bustato o riscrive il titolo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 07:36:43 +02:00
DevEnv nis2-agileandClaude Opus 4.8 7149e762f8 [DOCS] Contesto sessione 2026-07-30: Gestione Rischi 12 colonne (Modulo Azioni fase 1) v1.26.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 13:03:07 +02:00
DevEnv nis2-agileandClaude Opus 4.8 371e98c3c7 [RELEASE] v1.26.0 — Gestione Rischi 12 colonne (inerente+residuo). SW cache bump.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:59:54 +02:00
DevEnv nis2-agileandClaude Opus 4.8 ea57928226 [DOCS] Rischi 12 colonne: KB ARIA (inerente/residuo + eccezione conformità) + manuale d'uso
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:54:54 +02:00
DevEnv nis2-agileandClaude Opus 4.8 8a0bc5ca39 [FEAT] Gestione Rischi: tabella 12 colonne (inerente+residuo) — integrazione prototipo "Modulo Azioni" (Simon)
Integra la spec di Simon per la pagina Rischi con le correzioni normative
(parere nis2-expert + iso-27001-expert) applicate come eccezioni motivate:
- 12 colonne: Codice (link → Misure e Requisiti), Descrizione (RISCHIO DEFAULT =
  cfg_nis2_rischi.risk_descr), VALUTAZIONE RISCHIO INERENTE (P/I/PxI), Alert,
  Rischio Inerente, VALUTAZIONE RISCHIO RESIDUO (P/I/PxI), Esito residuo, Rischio Residuo.
- Editing INLINE di Probabilità/Impatto (1-5), salvataggio automatico.
- Alert a bande (15-25 Prioritaria / 9-14 Critica / 3-8 Necessaria / 1-2 Suggerita)
  → popup azioni + "crea azione" (tabella requisito_actions, seme Modulo Azioni).
- Eccezione #1: il rischio RESIDUO NON aggiorna in automatico lo STATO DI CONFORMITÀ del
  requisito (piani distinti). Col.11 "Esito residuo" = flag Rivalutare/Adeguato; conformità esplicita.
- Eccezione #3: banda 13-14 inclusa in "Critica" (9-14). "Danno €" → "Impatto" (1-5).
  Soglie = metodo interno (ISO 27001 §6.1.2), non obbligo (help/UI lo dichiarano).

Backend: mig 067; derivedList (+risk_descr +residuo), setState (+residuo), requisitoActions list/create.
help.js + i18n IT/EN. Cache-buster api/i18n/help 20260730a. Prossima mig=068.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:49:52 +02:00
DevEnv nis2-agileandClaude Opus 4.8 cf224480cc [FEAT] Sessione + release: refresh token silenzioso + avviso "nuova versione"
Evita la "sessione scaduta" e i deploy che restano invisibili all'utente.
- api.js: refresh PROATTIVO dell'access token ~2 min prima della scadenza
  (scheduleTokenRefresh su exp del JWT, riprogrammato ad ogni setTokens) +
  ensureFreshToken() per rinnovo immediato al ritorno sulla scheda. Con refresh
  a 7gg l'utente resta loggato senza mai vedere il login.
- common.js: watch versione (poll version.json ogni 5 min + su visibilitychange)
  → banner "È disponibile una nuova versione — Aggiorna" (reload) quando cambia;
  nudge service worker (registration.update); su visibilitychange chiama anche
  api.ensureFreshToken(). Nessun hard-refresh manuale.
- Cache-buster api/common ?v=20260729b. version 1.25.12. Solo frontend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 16:07:57 +02:00
DevEnv nis2-agileandClaude Opus 4.8 faadb62da6 [FEAT] Ruolo consulente: visibilità firm-scoped (org-switcher + Cruscotto Studio)
Un utente con role='consultant' e consulting_firm_id vede solo i CLIENTI del
proprio studio (organizations.consulting_firm_id = suo firm) UNION le proprie
membership dirette — non tutte le org (quello resta super_admin), non solo le
membership. Applicato in OrganizationController::list (selettore azienda) e
ConsultantController::portfolio (cruscotto). Additivo: super_admin e altri ruoli
invariati. Verificato: utente-test consultant/firm1 vede 2 aziende (cliente studio
996003 + membership 996001), non tutte. version 1.25.11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 15:53:54 +02:00
DevEnv nis2-agileandClaude Opus 4.8 a8516efafc [FIX] Gestione Rischi #501: tabella requisiti a scorrimento verticale + filtro per classe soggetto
- Punto 1: la tabella 'Rischi dai requisiti del framework di sicurezza' e ora
  un riquadro a scorrimento verticale (max-height 60vh, header sticky) invece di
  allungare la pagina.
- Punto 2: elenca SOLO i requisiti applicabili alla classe del soggetto
  (importante -> Allegato 1, essenziale -> Allegato 2, Det. ACN 164179/2025);
  le righe non pertinenti non sono piu mostrate (prima solo attenuate).
- help.js allineato + cache-buster help.js?v=20260729b su 35 pagine; version.json 1.25.10.
- Punto 3 (rimozione sezione all-hazard) NON applicato: contraddice la decisione
  documentata nel #500 (copertura multirischio art.24 D.Lgs.138/2024) -> escalato.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:11:36 +02:00
DevEnv nis2-agileandClaude Opus 4.8 15010f7dc3 [FEAT] Cruscotto Studio (frontend): companies.html vista d'insieme + menu
companies.html diventa il cruscotto dello studio consulente: barra KPI (aziende,
compliance media, scadenze in ritardo/in arrivo), griglia aziende (compliance/
rischi/incidenti + badge ritardo, clic = entra nell'azienda) e sezione "Scadenze
generali" aggregata su TUTTE le aziende ordinata per data. Una sola chiamata
api.consultantPortfolio(). Voce menu V3 "Le mie aziende" (icona briefcase) gata a
consultant/super_admin. help.js + i18n (nav.studio + companies.*) aggiornati.
Cache-buster api/topnav-v3/i18n/help ?v=20260729. version 1.25.9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 14:49:37 +02:00
DevEnv nis2-agileandClaude Opus 4.8 2bd97a6bd8 [FEAT] Cruscotto Studio (backend): ConsultantController::portfolio
GET /api/consultant/portfolio — vista d'insieme per consulente/studio: per ogni
azienda visibile all'utente (super_admin=tutte, altri=proprie membership) ritorna
compliance score, rischi/incidenti aperti, scadenze in ritardo/in arrivo; +
elenco SCADENZE AGGREGATE su tutte le aziende (incidenti CSIRT, policy, trattamenti
rischio, formazione) ordinate per data; + KPI d'insieme. Read-only, additivo.
Rotta registrata in index.php. Verificato: 5 aziende, 20 scadenze, compliance media 74%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 14:33:08 +02:00
DevEnv nis2-agileandClaude Opus 4.8 924963444e [FEAT] Rischi: suggerimento probabilità/impatto dallo stato di conformità
Nella modale "Valuta" di un rischio-requisito non ancora valutato, Probabilità e
Impatto vengono pre-compilati in base allo stato (conforme→basso 1/2, parziale→
medio 3/3, non conforme→alto 4/4) con nota esplicita "Valori suggeriti … è solo
un suggerimento, modificabili". Se già valutato, usa i valori salvati. Idea
pre-avallata dal nis2-expert (precompilare l'asse dallo stato = rischio residuo).
Solo frontend (risks.html); nessun valore imposto (art.24 non prescrive metodo).
version 1.25.8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 08:39:40 +02:00
DevEnv nis2-agileandClaude Opus 4.8 03796978c5 [DOCS] KB: modello pagina Rischi (rischi=requisiti, framing) + ingest
Doc SYSTEM per ARIA: pagina Rischi = requisiti del framework (Det. ACN 164179/2025),
framing 'rischio di non conformita', codice RSK interno (non ACN), matrice=rappresentazione
(art.24), rischi aggiuntivi all-hazard per admin. + scripts/ingest-risks-model.php.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 15:40:24 +02:00
DevEnv nis2-agileandClaude Opus 4.8 f6d2076684 [FEAT] Rischi #500 opzione B: matrice dai rischi-requisito (probabilità×impatto)
Ogni rischio derivato da un requisito puo' ora essere valutato con Probabilita' e
Impatto (1-5) e compare nella matrice 5x5.
- DB: mig 066 (org_requisito_state += likelihood/impact) + runner idempotente.
- Backend: FrameworkController::setState accetta likelihood/impact; RiskController
  ::derivedList ritorna likelihood/impact/score. Applicata su prod + reload.
- Frontend risks.html: colonne Probabilita'/Impatto/Rischio(P×I) + tasto "Valuta"
  (modale, riservato a super_admin/org_admin/compliance_manager); matrice calcolata
  client-side dai rischi derivati valutati (esclusi i non_applicabile, caveat
  nis2-expert) + rischi custom. Framing normativo invariato.
- help.js/i18n aggiornati; version 1.25.7; buster help/i18n ?v=20260727b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 15:32:59 +02:00
DevEnv nis2-agileandClaude Opus 4.8 f1156a81bb [FIX] Rischi #500: ripristino tasti Nuovo/AI LIMITATI ai profili admin (decisione utente)
Il supervisore autonomo (b5f936b) aveva RIMOSSO del tutto i tasti 'Nuovo Rischio'
e 'AI Suggerisci' applicando il ticket alla lettera. Decisione utente (Cristiano):
NON rimuoverli ma limitarli ai profili super_admin/org_admin — l'inserimento libero
serve a coprire i rischi all-hazard non legati a un singolo requisito (art.24
D.Lgs.138/2024, confermato nis2-expert). Ripristinato il gating (gateAdminActions):
i tasti compaiono solo agli admin, nascosti agli altri. version 1.25.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 15:20:44 +02:00
DevEnv nis2-agileandClaude Opus 4.8 b5f936bba2 [FEAT] Rischi #500 punto 4: rimossi tasti 'Nuovo Rischio' e 'AI Suggerisci' (elenco = requisiti Lex&ISO, sola lettura)
I rischi in Gestione Rischi corrispondono ai requisiti del framework (Determinazione ACN 164179/2025);
niente inserimento manuale/AI. Reverte l'admin-gating: la spec del richiedente e il piano originale
prevedevano la rimozione dei tasti. La sezione 'Rischi aggiuntivi' resta come elenco in sola lettura.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:13:48 +02:00
DevEnv nis2-agileandClaude Opus 4.8 4e7e75582a [FEAT] Rischi #500 fase 2 (frontend): elenco rischi = requisiti Lex&ISO
La pagina Gestione Rischi mostra come tabella primaria (read-only) i requisiti
del framework Lex&ISO come rischi, codice RSK-<misura>Com.N via /api/risks/derived,
filtrati per classe (importante All.1 / essenziale All.2). Framing normativo
(validato nis2-expert): ogni voce = "rischio derivante dal mancato/parziale
soddisfacimento del requisito", mai "requisito = rischio"; RSK e "92" NON come
codici/numeri ufficiali ACN; matrice = strumento di rappresentazione (art.24).
- Rischi CUSTOM mantenuti in sezione "Rischi aggiuntivi (all-hazard)".
- Tasti "Nuovo Rischio"/"AI Suggerisci" limitati a super_admin/org_admin (non
  rimossi): l'inserimento libero serve al multirischio (art.24 D.Lgs.138/2024).
- api.js derivedRisks(); help.js + i18n aggiornati; version 1.25.4; SW v1.25.4.
- Cache-buster api/help/i18n ?v=20260727r. Backend/DB non toccati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 14:15:03 +02:00
DevEnv nis2-agileandClaude Opus 4.8 571e151305 [FEAT] Rischi #500 fase 2 backend: endpoint /api/risks/derived (rischi dai requisiti LEX&ISO, codifica RSK-<misura>Com.N)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:41:11 +02:00
DevEnv nis2-agileandClaude Opus 4.8 7c711b727a [FEAT] Rischi: matrice sempre visibile, rimosse viste FAIR e KRI (ticket #500 fase 1)
Ticket #500 (Simon Fattori) - semplificazione pagina Gestione Rischi:
- matrice 5x5 ora sempre visibile sotto la tabella (rimosso bottone 'Matrice' e il view-toggle)
- rimosse le viste 'Quantitativo (FAIR)' e 'KRI' con relativi bottoni, blocchi HTML e JS
- help.js allineato (rimosse sezioni FAIR/KRI), cache-buster help.js -> 20260727a
- version.json -> 1.25.2
Rimandato a fase 2 (richiede modello dati): rischi derivati dai requisiti LEX&ISO
+ codifica RSK-<misura>Com.N + rimozione bottoni 'Nuovo Rischio'/'AI Suggerisci'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:15:22 +02:00
DevEnv nis2-agileandClaude Opus 4.8 896663d24c [FEAT] Dashboard: card Documenti istituzionali funzionante + UI (ticket #499)
- loadDocs: 5 voci standard sempre presenti + voci custom + 'Aggiungi voce'
- modale add/edit con descrizione OBBLIGATORIA (richiesta punto 1), link doc opzionale
- delete voci custom; controlli visibili a org_admin/compliance_manager/super_admin
- CSS .doc-* in v3.css; cache-buster dashboard.js/v3.css su dashboard.html; version 1.25.1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:15:26 +02:00
DevEnv nis2-agileandClaude Opus 4.8 1b65321ffb [FEAT] Documenti istituzionali dashboard: backend + mig 065 (ticket #499)
Card 'Documenti istituzionali' resa funzionale (era stato vuoto onesto).
- mig 065: tabella institutional_documents (per-org, descrizione obbligatoria)
- InstitutionalDocsController: list/save/delete (org-scoped, org_admin+compliance_manager)
- 5 voci standard sempre presenti: Codice Etico, Mission, Vision, Statuto, Piano Sanzionatorio
- registrato in index.php controllerMap/actionMap

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:13:13 +02:00
DevEnv nis2-agileandClaude Opus 4.8 ecb17e59f0 [DOCS] CONTEXT: mail a Massimo (rientro + installa app) + follow-up silent re-auth
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 14:35:45 +02:00
DevEnv nis2-agileandClaude Opus 4.8 006d06533f [DOCS] CONTEXT: v1.25.0 + fix bug sessione scaduta (segnalazione Massimo)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:12:10 +02:00
DevEnv nis2-agileandClaude Opus 4.8 41476c88f2 [FIX] api.js: sessione scaduta → login invece di pagina bloccata "--"
Bug segnalato da un tester "sono tornato su nis2 e non funziona più" (utente/
aziende "--", inventario bloccato su Caricamento, niente FAB segnalazioni).
Causa: un 401 su /auth/refresh (refresh token scaduto/invalido) rientrava nel
handler di refresh (la condizione non escludeva l'endpoint auth) → ricorsione:
doRefreshToken() non ritornava mai, quindi logout()/redirect a login non
scattava e la pagina restava rotta. Riprodotto con puppeteer (access scaduto +
refresh invalido → dashboard "--" identica allo screenshot).

Fix: escludi /auth/refresh e /auth/login dall'auto-refresh; inoltre un 401 senza
refresh token disponibile fa logout() → login. Ora un utente che torna dopo la
scadenza del token viene mandato a login (ri-accede) invece di vedere "--".
Bug PRE-ESISTENTE in api.js (non introdotto dal V3), critico ora col push tester.
Cache-buster api.js ?v=20260627u su tutte le pagine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:09:50 +02:00
DevEnv nis2-agileandClaude Opus 4.8 64e10b152b [RELEASE] v1.25.0 — UI V3: version.json + shell service worker aggiornati
- version.json 1.24.15 → 1.25.0 (MINOR: nuova interfaccia V3) con changelog
  utente; allinea footer/changelog e la versione nel bug-reporter alla UI reale.
- sw.js: cache 'nis2-shell-v1.23.0' → 'v1.25.0' + PRECACHE portato allo shell V3
  (v3.css/topnav-v3.js/inter/lucide al posto di style.css/common-bi/bootstrap-italia).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:26:36 +02:00
DevEnv nis2-agileandClaude Opus 4.8 e15e1b8645 [DOCS] CONTEXT_LAST_SESSION: V3 test completo + 2 bug fixati + polish + coda ticket vuota
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:40:58 +02:00
DevEnv nis2-agileandClaude Opus 4.8 a71d1a096d [FIX] V3 polish: spotlight ARIA su top-nav + CSP font segnala-anonimo
- aria-guide "Mostrami dov'è": ora aggancia le voci del menu IN ALTO V3
  (data-nav aggiunto in topnav-v3.js su voci dirette + mega-link). Se la voce è
  in un mega-menu, lo apre (.aria-guide-open) e ci punta lo spotlight; coach
  "menu in alto". Fallback navigazione invariato. (Prima cercava .sidebar-nav V2.)
- segnala-anonimo.html: CSP font-src 'self' → 'self' data: (sbloccato il font
  data: embeddato, niente più violazione CSP).
- Cache-buster: topnav-v3.js/common.js ?v=20260627t, aria-guide.js ?v=20260627t.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:57:21 +02:00
DevEnv nis2-agileandClaude Opus 4.8 95e77a668a [FIX] i18n V3: chiavi mr.title/normative.title mancanti (si vedeva la chiave)
La flotta V3 aveva messo data-i18n="mr.title"/"normative.title" sugli <h1> di
misure-requisiti e normative, ma le chiavi non erano in i18n.js → I18n.t()
ritorna la chiave → il titolo mostrava "mr.title"/"normative.title". Aggiunte
le 2 chiavi (it+en). Cache-buster i18n.js ?v=20260627p su tutte le pagine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:36:39 +02:00
DevEnv nis2-agileandClaude Opus 4.8 a223923fd6 [FIX] ARIA data-aware via chat: AiController risolve l'org per org_data_ok
ask() chiamava requireAuth() ma non requireOrgAccess(), quindi getCurrentOrgId()
restava null → org_data_ok sempre falso → ARIA rispondeva "non ho accesso ai dati"
anche con membership valida. Ora l'org si risolve da X-Organization-Id → param
org_id → currentOrgId → org primaria dell'utente; lo spoofing resta neutralizzato
dal controllo membership esistente. Verificato: ARIA ora cita i numeri reali
(rischi/incidenti/asset...) via /api/ai/ask. Bug pre-esistente (Fase B 24/6),
emerso nel test di rilascio V3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:18:47 +02:00
DevEnv nis2-agileandClaude Opus 4.8 9971fbc065 [DOCS] CONTEXT_LAST_SESSION: UI V3 live + allineamento help/i18n/KB/ARIA + mail tester
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:48:25 +02:00
DevEnv nis2-agileandClaude Opus 4.8 58b4bdbe5e [FIX] UI V3 — allineamento help/i18n/KB/ARIA al menu top-nav
Dopo il revamp V3, allineati i layer che descrivevano ancora la sidebar V2:
- topnav-v3.js: aggiunte le pagine orfane in nav (Matrice RACI → Struttura
  interna, Scadenziario → Monitoraggio, Compliance Journey → Guida) + cablato
  data-i18n su gruppi e voci del mega-menu (traduzione IT/EN come in V2).
- i18n.js: nuove chiavi nav V3 (gruppi mega-menu + connettori/whistleblowing/
  normative/cross-analysis/kb/architettura/simulazione/integrazioni/workflow/
  audit ACN/registra incidente).
- AIService.php navigationMapBlock: riscritta sul menu IN ALTO V3 (9 gruppi +
  pulsante "Registra incidente"), sync con topnav-v3.js NAV_GROUPS; ARIA non
  parla più di "barra laterale".
- help.js: corrette le 3 menzioni "sidebar" → barra/menu in alto.
- KB nis2_kb: nuovo docs/kb/V3_UI_NAVIGAZIONE.md + scripts/ingest-v3-ui-doc.php
  (ingerito host-side: 3 chunk SYSTEM, rimossi i chunk V2; ARIA RAG ora guida
  sul top-nav).
- Cache-buster ?v=20260627n sui 3 JS modificati su tutte le pagine.

Deploy: JS/HTML statici live via bind-mount; php-fpm reload (host + nis2-app)
per AIService.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:38:07 +02:00
DevEnv nis2-agileandClaude Opus 4.8 a4150b5389 [FEAT] UI V3 — flotta: chrome top-nav + design system su 32 pagine app
Migrazione UI dal V2 (Bootstrap Italia, sidebar) al V3 (top-nav mega-menu
navy/oro, Inter+Lucide self-host) fedele ai mockup docs/mockup Ui/.
- 32 pagine app convertite (incl. admin/*): head Inter+v3.css, chrome
  topnav-v3, contenuti in card V3, dati REALI preservati (id/data-* intatti),
  i18n/help/FAB reali invariati, nessun CDN esterno.
- v3.css: aggiunti widget condivisi (modal/toast/form/btn-variant/spinner/
  progress/tag/badge) + utility (.btn base, .text-muted, .sr-only) + alias
  variabili V2->token V3, così modali/form/toast di common.js funzionano
  ovunque senza style.css.
- maintenance.html: finestra estesa a ~12:15.
- docs/UI_V3_FLEET_BRIEF.md: ricetta operativa della flotta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:57:35 +02:00
DevEnv nis2-agileandClaude Opus 4.8 b0fc190704 [FEAT] UI V3 — foundation (v3.css + topnav-v3.js + Inter/Lucide self-host) + dashboard pilota fedele al mockup
Fase 0 della migrazione UI V3 (sidebar V2 → top-nav navy/oro, font Inter, icone Lucide).
- public/css/v3.css: design system portato fedelmente dal mockup index.html
  (token :root, chrome appbar+mega-menu, card/stat/calendario/anag/doc/fab, @media ≤900/≤560),
  + aggiunte minime V3+ per stati loading/empty/error, mobile nav toggle e touch≥44px (AGID/WCAG).
- public/vendor/inter/: Inter self-host (variable woff2 latin+latin-ext) + inter.css + README (NO Google CDN).
- public/vendor/lucide/lucide.min.js: Lucide UMD self-host (NO unpkg CDN).
- public/js/topnav-v3.js: appbar + mega-menu data-driven dalla mappa IA (§6, 9 gruppi),
  aria-current sulla voce attiva, riusa gli helper di common.js (utente/logout/versione/org-switcher,
  branding, impersonate) e i 3 FAB reali (ARIA + bug-reporter + notifiche). window.loadSidebar=loadTopnavV3.
- public/dashboard.html: convertita al V3 fedele al mockup, cablata sui DATI REALI via api.js
  (overview/compliance-score/upcoming-deadlines/recent-activity/organizations-current);
  monitor giornaliero/settimanale alimentati dagli eventi reali; documenti istituzionali = stato vuoto onesto.
- public/js/dashboard.js (nuovo): wiring dati reali + render calendario/scadenze/attività/anagrafica;
  stati loading/empty/error; zero dati finti del mockup.

node --check OK su topnav-v3.js e dashboard.js. Solo UI: nessun PHP/DB/api.js toccato.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:59:08 +02:00
DevEnv nis2-agileandClaude Opus 4.8 2a5d90f8bb [OPS] Manutenzione flag-gated: .htaccess (503→maintenance.html) + pagina + bypass token
Meccanismo riusabile: presenza di ../.maintenance (gitignored) → ogni richiesta 503 +
maintenance.html (mostra la finestra). Bypass ?mnt=<token>/cookie. Accendi: touch
.maintenance · Spegni: rm .maintenance. Attivato ora per il revamp UI V3 (~1h).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:42:29 +02:00
DevEnv nis2-agileandClaude Opus 4.8 b2fa1a163e [DOCS] UI V3 mockup: estratti index/assessment/calendario + zip (fonte visiva flotta)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:18:30 +02:00
DevEnv nis2-agileandClaude Opus 4.8 f9baf39599 [DOCS] UI V3 revamp — spec operativa per flotta di agenti (da mockup docs/mockup Ui)
Analisi dei 3 mockup (top-nav mega-menu, tema navy/oro, Inter, Lucide) → spec per
migrare l'UI dal V2 (Bootstrap Italia/sidebar) al V3: design system (token/chrome/
componenti), architettura migrazione (riusa api.js/auth/help/i18n/FAB, sostituisce
solo chrome+css), work-breakdown a fasi (F0 foundation+dashboard pilota; F1 flotta
1 agente/gruppo IA in worktree; F2 verifica), mappa IA, guardrail, acceptance per
pagina, decisioni utente aperte (§7). Mockup = fonte visiva, dati reali non mock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:18:09 +02:00
DevEnv nis2-agileandClaude Opus 4.8 eb69a4f22e [FEAT] Discovery agent AWS (EC2/RDS/S3/SG → assets-ingest)
Connettore di discovery cloud AWS parallelo all'agente di rete: elenca
EC2/RDS/S3 via AWS CLI (zero dipendenze npm) + flussi SG ingress aperti
verso 0.0.0.0/0, mappa in asset NIS2 e POSTa a /api/services/assets-ingest
(source=aws). Config via env (RESOURCES/AWS_REGION/MAX_ITEMS/DRY_RUN),
gestione errori robusta (aws assente o comando fallito → messaggio chiaro).
README aggiornato con sezione AWS + permessi IAM read-only minimi.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:15:55 +02:00
DevEnv nis2-agileandClaude Opus 4.8 b787c327c1 [FEAT] Canale anonimo Vocea: pagina pubblica pre-login (scheletro) + risposta a Vocea
segnala-anonimo.html: pagina PUBBLICA (no checkAuth, no sidebar, no common.js/api.js/
pwa.js, no analytics) che ospiterà l'iframe del canale Vocea, conforme alle 6 regole
anonimato StarGateWB: Referrer-Policy no-referrer (meta + iframe), CSP frame-src
app.vocea.cloud, slug del canale da ?canale=<slug> (nessun id del segnalante nell'URL),
postMessage con check event.origin + envelope {source:'vocea'} e NESSUNA persistenza
del recovery code (solo autosize). Dormiente finché non c'è lo slug/MS live.
+ docs/OUTGOING_TO_VOCEA_...: conferma origin frame-ancestors https://nis2.agile.software.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 08:29:03 +02:00
DevEnv nis2-agileandClaude Opus 4.8 1fe5b7ae61 [DOCS] StarGateWB/Vocea: direttive anonimato segnalante per embed widget + gap NIS2
Ricevute da Vocea (27/6) le 6 regole per preservare l'anonimato del segnalante
nell'embed del widget. Gap analysis NIS2: la pagina app «Segnalazioni»
(whistleblowing.html) è dietro login → NON usabile come canale anonimo; serve una
pagina PUBBLICA pre-login. Più: no-log apertura, no id nell'URL iframe, check
event.origin, Referrer-Policy no-referrer, CSP frame-src app.vocea.cloud.
Origin da dare a Vocea per frame-ancestors: https://nis2.agile.software.
Da applicare all'attivazione Vocea (oggi scaffold dormiente).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 08:22:23 +02:00
DevEnv nis2-agileandClaude Opus 4.8 01b016da50 [FEAT] Connettori Discovery — UI gestione per org (Fase 4)
Pagina connettori-discovery.html + connettori-discovery.js (DiscoveryUI): lista
connettori, crea (mostra la chiave API una sola volta + comando agente), dettaglio
con storico run, rigenera chiave, elimina. Solo org_admin.
- Voce sidebar V2 "Connettori Discovery" in Operativo (common-bi.js, data-nav auto).
- api.js: metodi list/create/get/update/delete/rotateDiscoveryKey.
- ARIA navigationMapBlock: aggiunta la voce (coerenza assistente↔menu).
- help.js: _pageMap + guida contestuale "Connettori Discovery" (multi-connettore,
  come funziona, sicurezza scope ingest:assets).
Smoke: pagina 200, /api/discovery-connectors/list 401 (auth). Cache-buster
common-bi/help/api -> 20260625d.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:03:31 +02:00
DevEnv nis2-agileandClaude Opus 4.8 3d0bec1f7f [FEAT] Discovery agent di riferimento (scan rete → auto-popola NIS2) — Fase 3
scripts/discovery-agent.mjs (Node 18+, zero dipendenze): scansiona CIDR/IP via
TCP-connect (no root), reverse-DNS, deduce tipo asset + criticità dalle porte aperte,
costruisce asset (external_ref=net:<ip>) + flussi di rete e li POSTa a
/api/services/assets-ingest col X-API-Key del connettore. Concorrenza, MAX_HOSTS,
DRY_RUN, isPrivate (RFC1918+loopback+link-local). + README_discovery_agent.md (uso,
variabili, cron, multi-connettore, estensione cloud, sicurezza).
Validato: node --check + DRY_RUN reale (listener temporaneo) → rileva porta aperta,
costruisce asset+flusso corretti.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:45:13 +02:00
DevEnv nis2-agileandClaude Opus 4.8 b8ac793c8f [FEAT] Connettori discovery rete/cloud per-org → auto-popola Inventario (backend, mig 064)
Più connettori per azienda; ogni connettore ha una api_key dedicata (scope ingest:assets)
che l'agente esterno usa per mappare e auto-popolare NIS2.
- mig 064: discovery_connectors (per-org, multi) + discovery_runs (storico) + network_flows (ID.AM-03).
- DiscoveryConnectorController (org_admin): CRUD connettori + emissione/rotazione api_key
  (mostrata 1 volta) + dettaglio con ultimi run.
- ServicesController::ingestAssets esteso: accetta anche "flows" (upsert network_flows,
  dedup external_ref) e, se la chiave appartiene a un connettore, registra discovery_run
  + aggiorna last_run del connettore. Auto-scoring rilevanza NIS2 già in bulkUpsert.
- AssetController::bulkUpsert: ora salva anche "dependencies" → popola la Mappa Dipendenze.
- Router: /api/discovery-connectors (list/create/{id}/update/delete/rotateKey).
Smoke E2E (HTTP 201): 2 asset scorati + 1 flusso + run tracciato + dipendenze persistite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:38:55 +02:00
DevEnv nis2-agileandClaude Opus 4.8 cc7c6e4cf2 [FIX] ARIA: regola anti-invenzione record — stop confabulazione su reclami/ticket
Bug (segnalato "risponde mele per pere"): chiedendo ad ARIA "analizza la segnalazione
di Massimo Tagliavini e com'è stata gestita", ARIA FABBRICAVA un record falso (segnalante
"CISO", data "25 gennaio 2025", citazione inventata, gravità/stato/processo) — i ticket
dei singoli utenti vivono in AgileHub, non nei dati di ARIA → confabulava.
Fix: regola ANTI-INVENZIONE vincolante (priorità massima) nel system prompt di askWithRag:
non inventare MAI record specifici (segnalazioni/reclami/ticket/incidenti/persone/date/
citazioni/esiti) non presenti nei DATI ORG o nel contesto; i reclami/ticket dei singoli
utenti sono un sistema separato non accessibile → dichiararlo e indicare dove consultarli,
senza inventare. Smoke: ora ARIA NEGA correttamente l'accesso, nessun dettaglio fabbricato.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:05:37 +02:00
DevEnv nis2-agileandClaude Opus 4.8 ab3e88299e [FEAT] Inventario: ordinamento lista per colonna (#442)
Click su un'intestazione (Nome/Tipo/Categoria/Criticita/Rilevanza NIS2/Owner/Stato)
ordina l'inventario; secondo click inverte (freccia ▲/▼, aria-sort).
Criticita e Rilevanza ordinano per valore reale, non alfabetico.
Client-side sull'inventario gia' caricato (no paginazione su assets), si combina
coi filtri, accessibile da tastiera. Help aggiornato + cache-buster help.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:41:34 +02:00
DevEnv nis2-agileandClaude Opus 4.8 87fc5bf959 [FEAT] Inventario: esportazione CSV in formato import-compatibile (#441)
Nuovo pulsante "Esporta" in assets.html: scarica l'intero inventario
(tutte le pagine) in CSV con le stesse colonne riconosciute
dall'import (name, asset_type, criticality, data_classification,
internet_facing, dependencies_count, regulated, external_ref, vendor,
location). I criteri data_classification/internet_facing/
dependencies_count/regulated sono ricostruiti da relevance_criteria con
mappatura reversibile (round-trip stabile, verificato 100/100 casi).
Parser import reso quote-aware (RFC-style) per round-trip lossless di
nomi con virgola; retro-compatibile con CSV non quotato.
help.js + cache-buster (?v=20260625b) su tutte le pagine. version 1.24.14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:13:22 +02:00
DevEnv nis2-agileandClaude Opus 4.8 b33e0d051e [FIX] ARIA polish post #439/#440: help Inventario allineato + regola anti-invenzione
Completa i 2 punti rimandati dal supervisore:
- help.js sezione Inventario: documentate le nuove funzioni che il supervisore ha
  aggiunto (commit 9c36604) → "Mappa Dipendenze" con il campo Dipendenze per popolarla;
  "Elimina bene" (cestino, org_admin) distinto dallo stato "dismesso".
- AIService::navigationMapBlock: 2 regole nuove → (5) NON citare nomi file .html/URL
  interni; (6) NON inventare pulsanti/funzioni/passi inesistenti, usare solo le funzioni
  della guida/contesto della pagina. Elimina l'allucinazione "rischi.html" e simili.
Smoke: ARIA cita Elimina + Mappa Dipendenze, zero nomi-file, "Gestione > Rischi".
Cache-buster help.js -> 20260625a. php-fpm reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:56:25 +02:00
DevEnv nis2-agileandClaude Opus 4.8 9c36604b2b [FEAT] Inventario: Mappa Dipendenze + eliminazione beni (#440, #439)
- Nuovo pulsante "Mappa Dipendenze" in toolbar: vista delle relazioni
  tra i beni (costruita client-side da allAssets, endpoint backend gia'
  esistente dependencyMap).
- Campo "Dipendenze" (multi-select) nel form bene: consente di compilare
  la mappa che ARIA indicava ma che non aveva UI. create/update salvano
  gia' il campo dependencies.
- Pulsante "Elimina" bene nella lista e nel dettaglio (DELETE /assets/{id}
  gia' esistente, richiede org_admin).
- Dettaglio bene: dipendenze mostrate per nome anziche' per ID grezzo.
- version.json -> 1.24.13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:43:54 +02:00
DevEnv nis2-agileandClaude Opus 4.8 3bd2f09a45 [DOCS] CONTEXT_LAST_SESSION 2026-06-24: Vocea scaffold + segnalazioni Massimo + Agile=studio + upgrade ARIA (A→D) + avviso tester
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:40:06 +02:00
DevEnv nis2-agileandClaude Opus 4.8 a982797fa2 [FEAT] ARIA voce naturale (proxy TTS nexus-voice-ms) + toggle voce — Fase C
Porting voce TRPG/AllTax (ElevenLabs "Sarah") sull'ARIA chat NIS2:
- AiController::tts() + route POST /api/ai/tts: proxy same-origin a nexus-voice-ms
  (config VOICE_MS_URL=172.21.0.1:4215, voce TTS_VOICE_ID=EXAVITQu4vr4xnSDxMaL,
  eleven_turbo_v2_5). requireAuth, cache MP3 sha256 (sys_get_temp_dir/nis2-tts-cache),
  validazioni (text<=800, voice_id allowlist), stream audio/mpeg, 502 se upstream non-audio.
- common.js: speak(answer) dopo ogni risposta ARIA (voce discreta vol .55, markdown
  strip), toggle 🔊/🔇 nell'header (muto persistito localStorage nis2_aria_voice),
  autoplay best-effort.
Smoke: voice-ms da nis2-app HTTP 200 audio/mpeg 19KB; /api/ai/tts 401 senza auth.
Reachability OK (no allowlist VOX necessaria). Cache-buster common.js -> 20260624g.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:58:05 +02:00
DevEnv nis2-agileandClaude Opus 4.8 fe7d0555c6 [FEAT] ARIA data-aware: snapshot dati org dell'utente nel prompt (Fase B)
Richiesta: "AI deve sapere i contenuti dei dati a cui ha accesso l'utente".
- AIService::orgDataSnapshotBlock($orgId): aggregati LIVE dell'org (compliance score,
  rischi aperti per livello + top 5, incidenti aperti, asset attivi/rilevanti NIS2,
  fornitori critici scoperti, NC aperte, formazione, policy per stato). Query con
  colonne reali (riuso DashboardController), ognuna in try/catch (degrada, non rompe).
- Iniettato in askWithRag SOLO se org_data_ok.
- SICUREZZA multi-tenant: AiController verifica membership in user_organizations
  (super_admin bypassa) → org_data_ok; scope rigoroso organization_id → niente leak
  cross-org da X-Organization-Id falsificato. PRIVACY: niente nome/fatturato (anonimizz.).
Smoke: con membership ARIA cita score 80% + 14 rischi (org 996003); senza membership
nega l'accesso ai dati. php-fpm reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:44:16 +02:00
DevEnv nis2-agileandClaude Opus 4.8 42529043d3 [FEAT] ARIA puntatore guida "Mostrami dov'è" — spotlight verso il menu (Fase D)
Porting del pattern TRPG (CHANGE_aria_voice_pointer.md) sulla sidebar V2:
- public/js/aria-guide.js (nuovo): DESTINATIONS (26 voci menu → keyword IT),
  matchDestination(question), pointTo(nav,label) = overlay + spotlight pulsante +
  puntatore animato + coach-mark "Vai →"; Esc/click per chiudere; fallback navigate
  se la voce non è visibile; rispetta prefers-reduced-motion; self-contained, no inline-CSS.
- common-bi.js: data-nav="<href senza .html>" sulle voci sidebar (aggancio puntatore).
- common.js: inietta aria-guide.js dopo login; dopo ogni risposta ARIA, se la domanda
  combacia con una destinazione mostra il bottone "📍 Mostrami dov'è: <voce>".
Frontend-only, additivo. Cache-buster common.js/common-bi.js -> 20260624f.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:39:47 +02:00
DevEnv nis2-agileandClaude Opus 4.8 bafc305503 [FEAT] ARIA allineata all'Help contestuale della pagina + pageId canonico (Fase A)
Lamentela: "AI non allineata con help online e contesto". Causa: ARIA e Help
erano due fonti separate (Help=help.js, ARIA=KB) → divergevano.
Fix (fonte unica = help.js, zero duplicazione):
- help.js: getContextText(pageId) (testo piano dell'help della pagina) + detectPageId().
- common.js: invia page_id (canonico) + page_help (stesso testo del pannello "?") a /api/ai/ask.
- AiController: passa page_id/page_help in userContext.
- AIService::askWithRag: inietta "GUIDA CONTESTUALE DELLA PAGINA" nel system prompt
  (ARIA si allinea all'help mostrato all'utente, non lo contraddice) + usa page_id
  per il trigger relevanceGrid (più robusto del title).
Smoke: ARIA cita "registro dei rischi" dall'help + "Gestione > Rischi". Cache-buster
common.js/help.js -> 20260624e (39 pagine).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:30:10 +02:00
DevEnv nis2-agileandClaude Opus 4.8 3b9f30a890 [FEAT] Agile Technology = studio di consulenza che gestisce sé stessa (consolidamento)
Opzione A (decisione utente 24/6): nessun duplicato, nessuna rinomina. Seeder
idempotente+transazionale build_agile_consulente.php:
- consulting_firm 1: anagrafica REALE (P.IVA 07776161213, sede legale Roma Piazza
  di Campitelli 2 00186 RM, PEC agile.software@pec.it) — erano dati placeholder.
- org 996003 (Nuova Agile Technology srl, P.IVA 07776161213): consulting_firm_id=1
  → prima azienda auto-gestita dallo studio.
- firm_org_assignments: studio 1 ↔ org 996003 ↔ 4 admin.
- users.consulting_firm_id=1 per Benassati(4, consulente), Silvia Garretto(103,
  legale rappr = presidenza@), Tagliavini(326), Fattori(330).
Manuale/procedure/policy (12 doc published) già su 996003, non toccati. Rollback nel file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:09:07 +02:00
DevEnv nis2-agileandClaude Opus 4.8 e5f7a7d3a6 [FIX] Widget Segnalazioni: cap descrizione 1900 char + contatore, evita errore 'Data too long operative_summary' (ticket #430)
La colonna operative_summary lato ticket-ms e' VARCHAR(2000): descrizioni
lunghe causavano l'errore SQL grezzo a video. Aggiunto maxlength + contatore
live + guard al submit nel widget locale public/js/bug-reporter.js.
Cache-buster: bug-reporter.js?v=20260624 in common.js + common.js?v=20260624
su tutte le pagine. Causa radice (dimensione colonna) inoltrata ad AgileHub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 03:11:19 +02:00
DevEnv nis2-agileandClaude Opus 4.8 08d4ac0cc4 [FIX] ARIA: inietta griglia esatta combo Rilevanza NIS2 nel prompt (ticket #429)
ARIA (askWithRag) conosceva solo il nome pagina + KB/fonti, non la griglia
reale delle tendine della finestra «Valuta Rilevanza NIS2» (AssetScoringService::GRID):
dava risposte generiche non corrispondenti alla finestra. Aggiunto
relevanceGridBlock() (6 criteri, opzioni, punteggi, desc dalla fonte di verita')
iniettato nel system prompt quando l'utente e nell'Inventario o chiede della
rilevanza. Additivo, nessun impatto su altri flussi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:09:24 +02:00
DevEnv nis2-agileandClaude Opus 4.8 cf23be9c77 [FEAT] Inventario Rilevanza NIS2: significato opzioni combo + dettaglio punteggio per criterio (ticket #431, #432)
- AssetScoringService::GRID: aggiunto campo 'desc' a tutte le 35 opzioni dei 6 criteri (significato operativo, additivo)
- assets.html: ogni combo mostra il significato dell'opzione selezionata + nuovo box 'Dettaglio punteggio per criterio' (live, anche alla riapertura di una valutazione salvata)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:43:43 +02:00
DevEnv nis2-agileandClaude Opus 4.8 ea5966eaee [FIX] ARIA: mappa navigazione reale + contesto pagina (ticket #424)
ARIA citava voci di menu inesistenti (es. 'A4 - Analisi e Valutazione
del Rischio' / 'Fase 4.1 - Inventario') non corrispondenti alla barra
laterale, e non sapeva da quale pagina scrivesse l'utente.

- AIService::askWithRag: iniettata la mappa REALE della sidebar
  (navigationMapBlock, sincronizzata con common-bi.js) con regole che
  vietano di inventare menu/fasi/codici; aggiunto contesto pagina utente.
- AiController::ask: accetta e passa il campo 'page' dal frontend.
- common.js (ARIA FAB): invia la pagina corrente in /api/ai/ask.
- Cache-buster common.js 20260623->20260623h su tutte le pagine.

NB: questo commit include anche il fix #419 gia' applicato e live in
common.js (email bug-reporter recuperata via api.getMe quando assente
nelle claims JWT), finora non committato — incluso come backup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:12:36 +02:00
DevEnv nis2-agileandClaude Opus 4.8 d45bd472cd [FIX] Vocea scaffold: rotta tenantInfo non versionata + portalUrl reale + namespace vault
Correzioni dallo smoke live su api.vocea.cloud (23/6, VIGILE/Vocea):
- tenantInfo(): /tenants/{slug}/info è sotto /api/wb (NON /api/wb/v1) → request() ora
  accetta baseOverride; aggiunto $apiBase (base senza /vN). I 3 /integrations/* restano /v1.
- portalUrl(): path reale /{locale}/segnala/{slug} (era /wb/{slug}); aggiunto
  portalStatusUrl() /{locale}/stato/{slug}; channelStatus espone portal_status_url.
- Vault namespace corretto: tier1__nis2-app__vocea (l'app reale è nis2-app, non nis2-agile).

Scaffold resta DORMIENTE (VOCEA_ENABLED=false). NB: VIGILE segnala che le route
/api/wb/v1/integrations sono ancora 404 (deploy MS incompleto) → non attivare finché
Vocea non completa il deploy e l'API key non è nel vault.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:50:50 +02:00
DevEnv nis2-agileandClaude Opus 4.8 637e1ec510 [FEAT] Inventario: rinomina/rimuovi sottoclassi org dalla modifica bene (ticket #426)
Aggiunti endpoint PUT/DELETE /api/assets/subclasses/{id} (org-scoped, le
sottoclassi di sistema restano protette; FK ON DELETE SET NULL scollega i
beni senza cancellarli) + link UI «rinomina»/«rimuovi» accanto a «aggiungi».
Cache-buster api.js + bump version 1.24.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:42:02 +02:00
DevEnv nis2-agileandClaude Opus 4.8 7529c59a75 [FIX] Inventario: combo Rilevanza NIS2 ripopolate alla riapertura (ticket #427)
La valutazione rilevanza salva relevance_criteria come mappa piatta
{criterio: optionValue} (AssetController::score), ma showScoringModal
leggeva prev[key].value (forma a oggetto) -> sempre undefined -> nessuna
combo pre-selezionata alla riapertura. Letto ora il valore-stringa diretto,
con fallback retro-compatibile alla forma a oggetto {value:...}.

Fix solo frontend (JS inline in assets.html), nessuna migrazione.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:36:33 +02:00
DevEnv nis2-agileandClaude Opus 4.8 bd1e95542c [FIX] ARIA chat: indicatore 'sta scrivendo' + input multi-riga (Shift+Invio)
Risolve #418/#420 (ARIA sembrava non rispondere / serviva riaprire il pannello):
l'attesa della risposta /api/ai/ask (RAG+Claude, alcuni secondi) non mostrava
alcun indicatore -> ora compare 'ARIA sta scrivendo...' con input disabilitato
(anti doppio-invio). Indicatore rispetta prefers-reduced-motion (AGID/WCAG).

Risolve #425: il campo di scrittura era <input> a riga singola -> ora <textarea>
con Invio=invia, Shift+Invio=a capo, auto-crescita e aria-label.

common.js e' statico (no PHP/USR2): bump cache-buster ?v=20260623 su 39 pagine
(public + admin) + version.json 1.24.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:13:38 +02:00
DevEnv nis2-agileandClaude Opus 4.8 4e2e56ec0b [FEAT] Vocea whistleblowing — scaffold integrazione (dormiente) + handoff AgileHub
Scaffold per sostituire il modulo whistleblowing interno (plaintext) con Vocea
(nexus-whistleblowing-ms), prodotto zero-knowledge della suite. Modalita C
(integrazione verticale, nessuna crypto re-implementata). DORMIENTE: VOCEA_ENABLED
=false di default -> zero impatto sul modulo legacy.

- docs/sql/063_vocea_channel.sql + application/cli/migrate_063_vocea_channel.php
  (idempotente): organizations += vocea_tenant_slug/channel_status/enrolled_at.
  NON ancora applicata al DB.
- application/services/VoceaService.php (nuovo): client API integratori (X-API-Key
  su api.vocea.cloud) per submit/status/reports/keyholders + portalUrl (embed
  wb-link.js). Guard enabled()/isEnabledForOrg(): se OFF/non provisioned ritorna
  DISABLED/NOT_PROVISIONED senza chiamate di rete.
- config.php: costanti VOCEA_ENABLED/BASE_URL/API_KEY/PORTAL_URL (default off).
- WhistleblowingController: branch dormiente in list() (delega a Vocea se attivo) +
  endpoint GET channelStatus + helper voceaOrg/listViaVocea. Modulo legacy invariato.
- public/index.php: route GET:channelStatus.
- docs handoff: richiesta a VIGILE (OUTGOING) + risposta VIGILE (INCOMING).

Attivazione a MS live: applicare migrate_063 + VOCEA_ENABLED=true + VOCEA_API_KEY
(vault tier1__nis2-agile__vocea__*) + slug canale su organizations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:07:49 +02:00
DevEnv nis2-agileandClaude Opus 4.8 ef9a7282c5 [DOCS] Contesto: sedi Nuova Agile (anagrafica completa) + mail a Simon con richieste ACN aperte (92 vs 87 + date Determinazioni)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:26:29 +02:00
DevEnv nis2-agileandClaude Opus 4.8 be44e33b81 [CLIENT] Nuova Agile: sedi (legale Roma + operativa Modena) + scope SGSI allineato
Anagrafica: sede legale Piazza di Campitelli 2, 00186 Roma (address/city); sede operativa Strada Scaglia Est 15, 41121 Modena (mission). Scope/contesto SGSI resi precisi: sedi registrate ma modello work-from-anywhere. Anagrafica Nuova Agile ora completa (P.IVA + sedi + classificazione + DPO).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:20:05 +02:00
DevEnv nis2-agileandClaude Opus 4.8 128c19ac79 [CLIENT] Nuova Agile: P.IVA + nomina DPO esterno (Cristiano Benassati) — TUTTE le 5 NC chiuse + contesto sessione
P.IVA 07776161213; DPO esterno Cristiano Benassati (user 4) in organigramma/SGSI/Manuale + membership. NCR-9005 chiusa -> 0 NC aperte. Determinazione NIS2 (p10) gia' committata in 51dc757; qui anche p10 script + p11 + aggiornamento CONTEXT_LAST_SESSION.md (blocco G: task #8/#9/#10 + determinazione + chiusura NC).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:02:55 +02:00
DevEnv nis2-agileandClaude Opus 4.8 51dc757352 [CLIENT] Nuova Agile: Determinazione di applicabilita NIS2 formalizzata + chiusura NC maggiore
Dati Direzione: fatturato 0,9M€(2026)/1,4M€(2027), ~12 persone → SOTTO-SOGLIA. Cloud provider (Allegato I) ma soggetto al cap dimensionale → NON obbligata per legge; la Direzione DELIBERA l'adozione VOLONTARIA integrale delle misure da soggetto 'importante'. Org: turnover+voluntary_compliance=1+designation_basis=voluntary. Manuale sez.11 'Caveat' → 'Determinazione di applicabilita NIS2' (+snapshot). NCR-9001 (maggiore) CHIUSA + CAPA-1010 completata. DPO: esterno deliberato (nominativo in corso) → NCR-9005 resta in correzione.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:51:19 +02:00
DevEnv nis2-agileandClaude Opus 4.8 05a1436d8c [I18N] Modulo Fornitori bilingue IT/EN (#8b) — v1.24.5
- supplier-portal.html (portale esterno OTP): i18n self-contained (dizionario inline + toggle IT/EN, ~79 stringhe), flusso OTP/token/ID/API invariato; domande questionario restano dati (non tradotte).
- supply-chain.html (interno): i18n estesa (5->25 data-i18n + 97 I18n.t).
- i18n.js: +82 chiavi sp.* (38->120). Cache-buster i18n.js su supply-chain.html. node --check OK, pagine 200, parita IT/EN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:32:18 +02:00
117 changed files with 13568 additions and 6050 deletions
+3
View File
@@ -54,3 +54,6 @@ docker/data/
# TLS-DB (VIGILE 2026-06-10): flag-file attivazione + CA locale (env-specifici, non committare) # TLS-DB (VIGILE 2026-06-10): flag-file attivazione + CA locale (env-specifici, non committare)
application/config/.db_ssl_on application/config/.db_ssl_on
application/config/db-ca.pem application/config/db-ca.pem
# Flag manutenzione (transitorio, runtime): presenza = sito in manutenzione (gate in .htaccess)
.maintenance
@@ -0,0 +1,83 @@
<?php
/**
* build_agile_consulente.php — "Agile Technology" come STUDIO DI CONSULENZA
* (consulting_firm id=1) che gestisce sé stessa (org 996003) come PRIMA azienda.
*
* Opzione A (consolidamento, decisione utente 2026-06-24): NESSUN duplicato, NESSUNA rinomina.
* IDEMPOTENTE + TRANSAZIONALE. Esegue:
* 1) consulting_firm 1: anagrafica REALE (P.IVA 07776161213, sede legale Roma
* Piazza di Campitelli 2 00186 RM, PEC agile.software@pec.it).
* 2) organizations 996003: consulting_firm_id=1 → azienda auto-gestita dallo studio.
* 3) firm_org_assignments: studio 1 ↔ org 996003 ↔ i 4 admin.
* 4) users: consulting_firm_id=1 per i 4 admin (Benassati consulente, Silvia legale
* rappr. = presidenza@ (l'utente reale; "presidente@" non esiste), Tagliavini, Fattori).
* Manuale/procedure/policy: GIÀ su 996003 (12 published) → NON toccati.
* NB schema: consulting_firms non ha campi legale_rappresentante/sede_operativa →
* legale rappr. = user 103; sede operativa Modena vive nei documenti SGSI dell'org.
*
* Esegui: docker exec nis2-app php /var/www/nis2-agile/application/cli/build_agile_consulente.php
* Rollback: vedi fondo file.
*/
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("CLI only\n"); }
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../config/database.php';
$pdo = Database::getInstance();
const FIRM_ID = 1;
const ORG_ID = 996003;
const ADMINS = [4, 103, 326, 330]; // Benassati(consulente) · Silvia(legale rappr) · Tagliavini · Fattori
const ASSIGNED_BY = 4; // il consulente
function one($pdo, $sql, $p = []) { $s = $pdo->prepare($sql); $s->execute($p); return $s->fetch(PDO::FETCH_ASSOC); }
$inList = implode(',', ADMINS);
echo "=== STATO PRIMA ===\n";
echo "firm 1 : " . json_encode(one($pdo, "SELECT name,vat_number,address,city,cap,pec FROM consulting_firms WHERE id=?", [FIRM_ID]), JSON_UNESCAPED_UNICODE) . "\n";
echo "org 996003 : cf_id=" . json_encode(one($pdo, "SELECT consulting_firm_id FROM organizations WHERE id=?", [ORG_ID])) . "\n";
echo "users : " . json_encode($pdo->query("SELECT id,consulting_firm_id FROM users WHERE id IN ($inList)")->fetchAll(PDO::FETCH_KEY_PAIR)) . "\n";
echo "assignments: " . (int)$pdo->query("SELECT COUNT(*) FROM firm_org_assignments WHERE consulting_firm_id=" . FIRM_ID . " AND organization_id=" . ORG_ID)->fetchColumn() . "\n\n";
$pdo->beginTransaction();
try {
$pdo->prepare("UPDATE consulting_firms SET vat_number=?, fiscal_code=?, forma_giuridica=?, address=?, city=?, province=?, cap=?, pec=?, website=?, updated_at=NOW() WHERE id=?")
->execute(['07776161213', '07776161213', 'SRL', 'Piazza di Campitelli, 2', 'Roma', 'RM', '00186', 'agile.software@pec.it', 'https://agile.software', FIRM_ID]);
echo "[1] consulting_firm 1: anagrafica reale aggiornata\n";
$pdo->prepare("UPDATE organizations SET consulting_firm_id=? WHERE id=?")->execute([FIRM_ID, ORG_ID]);
echo "[2] org 996003.consulting_firm_id=1 (azienda auto-gestita)\n";
$ins = 0;
foreach (ADMINS as $uid) {
$ex = (int)one($pdo, "SELECT COUNT(*) c FROM firm_org_assignments WHERE consulting_firm_id=? AND organization_id=? AND assigned_to=?", [FIRM_ID, ORG_ID, $uid])['c'];
if ($ex === 0) {
$pdo->prepare("INSERT INTO firm_org_assignments (consulting_firm_id,organization_id,assigned_to,assigned_by,created_at) VALUES (?,?,?,?,NOW())")
->execute([FIRM_ID, ORG_ID, $uid, ASSIGNED_BY]);
$ins++;
}
}
echo "[3] firm_org_assignments: +$ins nuove (su " . count(ADMINS) . " admin)\n";
$pdo->prepare("UPDATE users SET consulting_firm_id=? WHERE id IN ($inList)")->execute([FIRM_ID]);
echo "[4] users.consulting_firm_id=1 per $inList\n";
$pdo->commit();
echo "\n=== COMMIT OK ===\n";
} catch (Throwable $e) {
$pdo->rollBack();
echo "ROLLBACK (nessuna modifica): " . $e->getMessage() . "\n";
exit(1);
}
echo "\n=== STATO DOPO ===\n";
echo "firm 1 : " . json_encode(one($pdo, "SELECT name,vat_number,fiscal_code,address,city,province,cap,pec FROM consulting_firms WHERE id=?", [FIRM_ID]), JSON_UNESCAPED_UNICODE) . "\n";
echo "org 996003 : " . json_encode(one($pdo, "SELECT name,vat_number,consulting_firm_id FROM organizations WHERE id=?", [ORG_ID]), JSON_UNESCAPED_UNICODE) . "\n";
echo "users : " . json_encode($pdo->query("SELECT id,email,role,consulting_firm_id FROM users WHERE id IN ($inList)")->fetchAll(PDO::FETCH_ASSOC), JSON_UNESCAPED_UNICODE) . "\n";
echo "assignments: " . json_encode($pdo->query("SELECT assigned_to,assigned_by FROM firm_org_assignments WHERE consulting_firm_id=" . FIRM_ID . " AND organization_id=" . ORG_ID . " ORDER BY assigned_to")->fetchAll(PDO::FETCH_ASSOC)) . "\n";
echo "doc SGSI 996003 (published): " . (int)$pdo->query("SELECT COUNT(*) FROM isms_documents WHERE organization_id=" . ORG_ID . " AND status='published'")->fetchColumn() . "\n";
/* ROLLBACK manuale:
UPDATE consulting_firms SET vat_number='IT12345670962',fiscal_code='12345670962',address='Via dei Consulenti 42',city='Milano',province='MI',cap='20121',pec='agiletech@pec.it' WHERE id=1;
UPDATE organizations SET consulting_firm_id=NULL WHERE id=996003;
DELETE FROM firm_org_assignments WHERE consulting_firm_id=1 AND organization_id=996003;
UPDATE users SET consulting_firm_id=NULL WHERE id IN (4,103); -- (326,330 erano GIÀ firm 1)
*/
@@ -0,0 +1,51 @@
<?php
/**
* build_nuova_agile_p10_determinazione.php — Formalizza la Determinazione di applicabilità NIS2 di Nuova Agile.
* Dati Direzione: fatturato 2026 0,9M€ / 2027 1,4M€; cloud provider (Allegato I); SOTTO-SOGLIA → non obbligata per legge
* → la Direzione DELIBERA l'adozione VOLONTARIA integrale delle misure da soggetto "importante". Chiude NCR-9001. 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);}
$NOW='2026-06-20 17:00:00'; $TODAY='2026-06-20'; $ORGID=996003;
/* 1) Org: fatturato + adesione volontaria deliberata (sotto-soglia) */
ex($pdo,"UPDATE organizations SET annual_turnover_eur=900000, voluntary_compliance=1, designation_basis='voluntary',
entity_type='important', updated_at=? WHERE id=?",[$NOW,$ORGID]);
echo "ORG aggiornata: fatturato 900k, voluntary_compliance=1, designation_basis=voluntary, entity_type=important\n";
/* 2) DPO: consulente esterno deliberato (nominativo in corso) */
ex($pdo,"UPDATE org_roles SET holder_user_id=NULL,
description='Presidio GDPR e ISO 27018 (dati personali nel cloud), DPIA, gestione richieste interessati. DPO affidato a CONSULENTE ESTERNO (deliberato dalla Direzione il 20/06/2026; acquisizione nominativo e lettera d\'incarico in corso). Coordinamento ad interim del RSGSI nelle more.',
updated_at=? WHERE organization_id=? AND role_name LIKE 'Referente Protezione Dati%'",[$NOW,$ORGID]);
echo "DPO role aggiornato (consulente esterno deliberato)\n";
/* 3) Manuale (id 37): sostituisci la sez. 11 'Caveat' con la Determinazione formalizzata + snapshot */
$new = '<h2>11. Determinazione di applicabilità NIS2</h2>'
.'<p><strong>Determinazione formalizzata dalla Direzione il 20/06/2026.</strong> Sulla base dei dati dimensionali (fatturato 2026 circa 0,9 M&euro;, previsione 2027 circa 1,4 M&euro;; 9 dipendenti e 2 collaboratori esterni a P.IVA), l\'Azienda &egrave; una <strong>impresa piccola/micro, SOTTO le soglie dimensionali NIS2</strong> (soggetto obbligato = impresa media o grande: &ge;50 addetti oppure fatturato/bilancio &gt;10 M&euro;). L\'attivit&agrave; rientra tra i <strong>fornitori di servizi di cloud computing</strong> (Allegato I &mdash; infrastrutture digitali); per i provider cloud <strong>vale comunque il cap dimensionale</strong>, pertanto un\'azienda sotto-soglia <strong>non &egrave; soggetto obbligato per legge</strong>. Un eventuale obbligo per un soggetto sotto-soglia potrebbe derivare unicamente da una <strong>designazione dell\'ACN</strong> ai sensi dell\'art. 3, comma 9, del D.Lgs. 138/2024 (es. unico fornitore nazionale, impatto sistemico), allo stato non riscontrata.</p>'
.'<p><strong>Decisione della Direzione:</strong> pur non essendo soggetto obbligato, l\'Azienda <strong>delibera l\'adozione VOLONTARIA e integrale delle misure di un soggetto &ldquo;importante&rdquo;</strong>, per rigore di sicurezza e per soddisfare i <strong>requisiti di supply chain dei clienti</strong> (alcuni dei quali soggetti NIS2 obbligati, art. 24 D.Lgs. 138/2024). La presente determinazione &egrave; riesaminata al superamento delle soglie dimensionali, al cambiamento della categoria di servizio o a fronte di una designazione ACN.</p>'
.'<p>Quadro di riferimento: Direttiva (UE) 2022/2555 recepita dal <strong>D.Lgs. 138/2024</strong> &mdash; art. 23 (governance e organi di gestione), art. 24 (misure di gestione del rischio), art. 25 (notifica al CSIRT: pre-allarme entro 24 ore, notifica entro 72 ore, relazione finale entro 1 mese). Le misure di dettaglio sono definite dalle Determinazioni ACN. <strong>Le norme ISO citate sono buone prassi, non obblighi di legge.</strong></p>';
$b=$pdo->query("SELECT body_html FROM isms_documents WHERE id=37")->fetchColumn();
$nb=preg_replace('#<h2>11\. Caveat sulla classificazione NIS2</h2>.*?(?=<h2>)#s',$new,$b,1,$cnt);
if($cnt){
ex($pdo,"UPDATE isms_documents SET body_html=?, updated_at=? WHERE id=37",[$nb,$NOW]);
$vid=$pdo->query("SELECT id FROM isms_document_versions WHERE document_id=37 AND version='2.0' ORDER BY id DESC LIMIT 1")->fetchColumn();
if($vid) ex($pdo,"UPDATE isms_document_versions SET body_html=? WHERE id=?",[$nb,$vid]);
echo "MANUALE: sez.11 sostituita con la Determinazione formalizzata (+ snapshot)\n";
} else echo "!! sezione caveat non trovata nel Manuale (verificare)\n";
/* 4) Chiudi NC maggiore NCR-9001 + CAPA-1010 */
$rc='Determinazione di applicabilità formalizzata dalla Direzione il 20/06/2026: azienda SOTTO-SOGLIA (fatturato <10 M€, <50 addetti); cloud provider (Allegato I) soggetto al cap dimensionale → non obbligata per legge; la Direzione delibera l\'adozione volontaria integrale delle misure da soggetto "importante". Dati dimensionali acquisiti. Eventuale obbligo solo via designazione ACN (art.3 c.9), non riscontrata.';
ex($pdo,"UPDATE non_conformities SET status='closed', actual_close_date=?, root_cause_analysis=?, updated_at=? WHERE id=16",[$TODAY,$rc,$NOW]);
ex($pdo,"UPDATE capa_actions SET status='completed', completion_date=?, is_effective=1, effectiveness_review='Determinazione formalizzata e documentata nel Manuale (sez.11); classificazione allineata (adesione volontaria deliberata).', updated_at=? WHERE id=3",[$TODAY,$NOW]);
echo "NCR-9001 (maggiore) CHIUSA + CAPA-1010 completata\n";
/* 5) NC DPO NCR-9005: decisione presa (esterno), nominativo in corso → resta in correzione */
ex($pdo,"UPDATE non_conformities SET root_cause_analysis='Ruolo DPO non coperto da figura formalmente nominata. Decisione della Direzione (20/06/2026): affidamento a CONSULENTE ESTERNO; acquisizione nominativo e lettera d\'incarico in corso. Coordinamento ad interim del RSGSI.', updated_at=? WHERE id=20",[$NOW]);
ex($pdo,"UPDATE capa_actions SET title='Nominare formalmente il DPO esterno (deliberato): acquisire nominativo e lettera d\'incarico', updated_at=? WHERE id=7",[$NOW]);
echo "NCR-9005 (DPO) aggiornata: esterno deliberato, nomina in corso\n";
echo "\nFASE 10 (determinazione) OK\n";
@@ -0,0 +1,50 @@
<?php
/**
* build_nuova_agile_p11_piva_dpo.php — Completa anagrafica (P.IVA) + nomina DPO esterno Cristiano Benassati.
* Chiude l'ultima NC (NCR-9005). 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);}
$NOW='2026-06-20 17:30:00'; $TODAY='2026-06-20'; $ORGID=996003; $DPO=4; // Cristiano Benassati
/* 1) Anagrafica: P.IVA / codice fiscale */
ex($pdo,"UPDATE organizations SET vat_number='07776161213', fiscal_code='07776161213', updated_at=? WHERE id=?",[$NOW,$ORGID]);
echo "ORG: P.IVA 07776161213 (IT) impostata\n";
/* 2) Benassati membro dell'org (auditor = oversight DPO) */
if(!one($pdo,"SELECT id FROM user_organizations WHERE user_id=? AND organization_id=?",[$DPO,$ORGID])){
ex($pdo,"INSERT INTO user_organizations (user_id,organization_id,role,is_primary,joined_at) VALUES (?,?, 'auditor',0,?)",[$DPO,$ORGID,$NOW]);
echo " membership + Cristiano Benassati (auditor/DPO)\n";
} else echo " membership = già presente\n";
/* 3) Ruolo DPO nell'organigramma (org_roles) + isms_roles */
ex($pdo,"UPDATE org_roles SET holder_user_id=?,
description='Presidio GDPR e ISO 27018 (dati personali nel cloud), DPIA, gestione richieste interessati. DPO affidato a CONSULENTE ESTERNO: Cristiano Benassati (nominato dalla Direzione il 20/06/2026).',
updated_at=? WHERE organization_id=? AND role_name LIKE 'Referente Protezione Dati%'",[$DPO,$NOW,$ORGID]);
ex($pdo,"UPDATE isms_roles SET user_id=? WHERE isms_model_id=3 AND role_name LIKE '%Privacy%'",[$DPO]);
echo "DPO nominato: Cristiano Benassati (org_role + isms_role)\n";
/* 4) Manuale (id 37): nome del DPO + snapshot */
$b=$pdo->query("SELECT body_html FROM isms_documents WHERE id=37")->fetchColumn();
$nb=str_replace('Consulente esterno (da nominare)','Consulente esterno: Cristiano Benassati',$b,$c);
if($c){
ex($pdo,"UPDATE isms_documents SET body_html=?, updated_at=? WHERE id=37",[$nb,$NOW]);
$vid=$pdo->query("SELECT id FROM isms_document_versions WHERE document_id=37 AND version='2.0' ORDER BY id DESC LIMIT 1")->fetchColumn();
if($vid) ex($pdo,"UPDATE isms_document_versions SET body_html=? WHERE id=?",[$nb,$vid]);
echo "MANUALE: DPO nominato (Cristiano Benassati) — $c sostituzione/i + snapshot\n";
} else echo " (stringa DPO nel Manuale non trovata, salto)\n";
/* 5) Chiudi NCR-9005 (DPO) + CAPA-5010 */
ex($pdo,"UPDATE non_conformities SET status='closed', actual_close_date=?,
root_cause_analysis='Ruolo DPO ora coperto: nominato dalla Direzione il 20/06/2026 il consulente esterno Cristiano Benassati. NC risolta.', updated_at=? WHERE id=20",[$TODAY,$NOW]);
ex($pdo,"UPDATE capa_actions SET status='completed', completion_date=?, is_effective=1,
effectiveness_review='DPO esterno nominato (Cristiano Benassati); ruolo assegnato in organigramma e SGSI.', updated_at=? WHERE id=7",[$TODAY,$NOW]);
echo "NCR-9005 (DPO) CHIUSA + CAPA-5010 completata\n";
/* riepilogo NC */
$open=$pdo->query("SELECT COUNT(*) FROM non_conformities WHERE organization_id=$ORGID AND status NOT IN('closed','cancelled')")->fetchColumn();
echo "\nNC ancora aperte: $open\nFASE 11 OK\n";
@@ -0,0 +1,34 @@
<?php
/**
* build_nuova_agile_p12_sedi.php — Anagrafica sedi: sede legale Roma + sede operativa Modena.
* Allinea lo scope SGSI (work-from-anywhere ma con sedi registrate). 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);}
$NOW='2026-06-20 18:00:00'; $ORGID=996003;
/* 1) Org: sede legale (address+city) + sede operativa in mission */
ex($pdo,"UPDATE organizations SET address='Piazza di Campitelli, 2', city='Roma', updated_at=? WHERE id=?",[$NOW,$ORGID]);
$mission=$pdo->query("SELECT mission FROM organizations WHERE id=$ORGID")->fetchColumn();
if(strpos($mission,'Modena')===false){
$mission=rtrim($mission).' Sede legale: Piazza di Campitelli 2, 00186 Roma. Sede operativa: Strada Scaglia Est 15, 41121 Modena.';
ex($pdo,"UPDATE organizations SET mission=? WHERE id=$ORGID",[$mission]);
}
echo "ORG: sede legale Roma (address/city) + sede operativa Modena (mission)\n";
/* 2) isms_models: scope/contesto precisi (sedi registrate, modello work-from-anywhere) */
$m=$pdo->query("SELECT id,scope_statement,context_internal FROM isms_models WHERE organization_id=$ORGID")->fetch(PDO::FETCH_ASSOC);
$scope=str_replace(
'NESSUNA sede fissa ne perimetro fisico aziendale: il lavoro si svolge da qualsiasi luogo.',
'L\'Azienda ha sede legale a Roma (Piazza di Campitelli 2, 00186) e sede operativa a Modena (Strada Scaglia Est 15, 41121); il personale opera secondo il modello work-from-anywhere (da qualsiasi luogo), senza un ufficio presidiato di routine.',
$m['scope_statement'],$c1);
$ctx=str_replace('senza uffici fissi','con sede legale a Roma e sede operativa a Modena, modello work-from-anywhere',$m['context_internal'],$c2);
ex($pdo,"UPDATE isms_models SET scope_statement=?, context_internal=?, updated_at=? WHERE id=?",[$scope,$ctx,$NOW,$m['id']]);
echo "ISMS scope/contesto aggiornati (scope x$c1, contesto x$c2)\n";
$o=$pdo->query("SELECT vat_number,address,city FROM organizations WHERE id=$ORGID")->fetch(PDO::FETCH_ASSOC);
echo "Anagrafica: ".json_encode($o,JSON_UNESCAPED_UNICODE)."\n";
echo "FASE 12 OK\n";
@@ -0,0 +1,36 @@
<?php
/**
* migrate_063_vocea_channel.php — Integrazione Vocea (whistleblowing zero-knowledge).
* organizations += vocea_tenant_slug, vocea_channel_status, vocea_enrolled_at. IDEMPOTENTE.
* SCAFFOLD DORMIENTE: nessun effetto funzionale finché VOCEA_ENABLED=false (default).
*
* Eseguire (host): docker exec nis2-app php /var/www/nis2-agile/application/cli/migrate_063_vocea_channel.php
*/
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 colExists(PDO $pdo, string $table, string $col): bool {
$st = $pdo->prepare(
"SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?"
);
$st->execute([$table, $col]);
return (bool)$st->fetchColumn();
}
$adds = [
'vocea_tenant_slug' => "ADD COLUMN vocea_tenant_slug VARCHAR(64) NULL COMMENT 'Slug canale Vocea (NULL=nessuno)'",
'vocea_channel_status' => "ADD COLUMN vocea_channel_status ENUM('none','provisioning','active','suspended') NOT NULL DEFAULT 'none'",
'vocea_enrolled_at' => "ADD COLUMN vocea_enrolled_at TIMESTAMP NULL COMMENT 'Enrollment canale + key holder'",
];
foreach ($adds as $col => $clause) {
if (colExists($pdo, 'organizations', $col)) { echo " - $col gia presente, skip\n"; continue; }
$pdo->exec("ALTER TABLE organizations $clause");
echo " + aggiunta colonna organizations.$col\n";
}
echo "Migrazione 063 — organizations.vocea_* OK. Prossima mig=064.\n";
@@ -0,0 +1,65 @@
<?php
/**
* migrate_064_discovery_connectors.php — Connettori discovery + run + flussi di rete. IDEMPOTENTE.
* Esegui: docker exec nis2-app php /var/www/nis2-agile/application/cli/migrate_064_discovery_connectors.php
*/
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();
$pdo->exec("CREATE TABLE IF NOT EXISTS discovery_connectors (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
name VARCHAR(120) NOT NULL,
connector_type ENUM('network','aws','azure','gcp','cmdb','agent','custom') NOT NULL DEFAULT 'network',
config JSON NULL,
api_key_id INT UNSIGNED NULL,
status ENUM('active','paused','error') NOT NULL DEFAULT 'active',
schedule ENUM('manual','hourly','daily','weekly') NOT NULL DEFAULT 'manual',
last_run_at TIMESTAMP NULL,
last_status VARCHAR(20) NULL,
last_discovered INT NULL,
last_message VARCHAR(255) NULL,
created_by INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_dc_org_name (organization_id, name),
INDEX idx_dc_org (organization_id),
INDEX idx_dc_key (api_key_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
echo " + discovery_connectors OK\n";
$pdo->exec("CREATE TABLE IF NOT EXISTS discovery_runs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
connector_id INT UNSIGNED NOT NULL,
organization_id INT NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP NULL,
status ENUM('running','ok','error') NOT NULL DEFAULT 'ok',
discovered_assets INT NOT NULL DEFAULT 0,
discovered_flows INT NOT NULL DEFAULT 0,
message VARCHAR(255) NULL,
INDEX idx_dr_conn (connector_id),
INDEX idx_dr_org (organization_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
echo " + discovery_runs OK\n";
$pdo->exec("CREATE TABLE IF NOT EXISTS network_flows (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
src VARCHAR(190) NOT NULL,
dst VARCHAR(190) NOT NULL,
port INT NULL,
protocol VARCHAR(12) NULL,
direction ENUM('internal','inbound','outbound') NOT NULL DEFAULT 'internal',
discovery_source VARCHAR(40) NOT NULL DEFAULT 'manual',
external_ref VARCHAR(190) NULL,
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_flow (organization_id, external_ref),
INDEX idx_flow_org (organization_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
echo " + network_flows OK\n";
echo "Migrazione 064 — connettori discovery OK. Prossima mig=065.\n";
@@ -0,0 +1,27 @@
<?php
/**
* migrate_065_institutional_documents.php — Documenti istituzionali per-org (dashboard). IDEMPOTENTE.
* Ticket #499. Esegui: docker exec nis2-app php /var/www/nis2-agile/application/cli/migrate_065_institutional_documents.php
*/
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();
$pdo->exec("CREATE TABLE IF NOT EXISTS institutional_documents (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
doc_key VARCHAR(60) NULL,
title VARCHAR(200) NOT NULL,
description TEXT NOT NULL,
file_url VARCHAR(500) NULL,
is_standard TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_by INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_instdoc_org_key (organization_id, doc_key),
INDEX idx_instdoc_org (organization_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
echo " + institutional_documents OK\n";
echo "Migrazione 065 — documenti istituzionali OK. Prossima mig=066.\n";
@@ -0,0 +1,36 @@
<?php
/**
* migrate_066_requisito_risk_scoring.php — Ticket #500 (opzione B, matrice).
* org_requisito_state += likelihood TINYINT NULL, impact TINYINT NULL (scala 1-5).
* Serve a posizionare i rischi-da-requisito (RSK-<misura>Com.N) sulla matrice 5x5.
* Additiva e IDEMPOTENTE. Nessun dato esistente toccato (valori NULL = non ancora valutato).
*
* Eseguire (host): docker exec nis2-app php /var/www/nis2-agile/application/cli/migrate_066_requisito_risk_scoring.php
*/
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 colExists(PDO $pdo, string $table, string $col): bool {
$st = $pdo->prepare(
"SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?"
);
$st->execute([$table, $col]);
return (bool) $st->fetchColumn();
}
$adds = [
'likelihood' => "ADD COLUMN likelihood TINYINT NULL COMMENT 'Probabilita 1-5 (rischio da non conformita del requisito)' AFTER valutazione_rischio",
'impact' => "ADD COLUMN impact TINYINT NULL COMMENT 'Impatto 1-5' AFTER likelihood",
];
foreach ($adds as $col => $clause) {
if (colExists($pdo, 'org_requisito_state', $col)) { echo " - $col gia presente, skip\n"; continue; }
$pdo->exec("ALTER TABLE org_requisito_state $clause");
echo " + aggiunta colonna org_requisito_state.$col\n";
}
echo "Migrazione 066 — org_requisito_state.likelihood/impact OK. Prossima mig=067.\n";
@@ -0,0 +1,64 @@
<?php
/**
* migrate_067_requisito_residual_and_actions.php — Integrazione "Modulo Azioni" (Simon), Fase 1.
* (1) org_requisito_state += residual_likelihood, residual_impact (TINYINT NULL, scala 1-5).
* (2) requisito_actions (azioni collegate al rischio-da-requisito).
* Additiva e IDEMPOTENTE. Nessun dato esistente toccato.
*
* Eseguire (host): docker exec nis2-app php /var/www/nis2-agile/application/cli/migrate_067_requisito_residual_and_actions.php
*/
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 colExists(PDO $pdo, string $table, string $col): bool {
$st = $pdo->prepare(
"SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?"
);
$st->execute([$table, $col]);
return (bool) $st->fetchColumn();
}
function tblExists(PDO $pdo, string $table): bool {
$st = $pdo->prepare(
"SELECT 1 FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?"
);
$st->execute([$table]);
return (bool) $st->fetchColumn();
}
$adds = [
'residual_likelihood' => "ADD COLUMN residual_likelihood TINYINT NULL COMMENT 'Probabilita residua 1-5' AFTER impact",
'residual_impact' => "ADD COLUMN residual_impact TINYINT NULL COMMENT 'Impatto residuo 1-5' AFTER residual_likelihood",
];
foreach ($adds as $col => $clause) {
if (colExists($pdo, 'org_requisito_state', $col)) { echo " - $col gia presente, skip\n"; continue; }
$pdo->exec("ALTER TABLE org_requisito_state $clause");
echo " + aggiunta colonna org_requisito_state.$col\n";
}
if (tblExists($pdo, 'requisito_actions')) {
echo " - tabella requisito_actions gia presente, skip\n";
} else {
$pdo->exec(
"CREATE TABLE requisito_actions (
id INT AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
requisito_id INT NOT NULL,
risk_code VARCHAR(64) NULL,
description TEXT NOT NULL,
status ENUM('bozza','in_corso','archiviata') NOT NULL DEFAULT 'bozza',
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_reqact_org_req (organization_id, requisito_id, created_at),
INDEX idx_reqact_org (organization_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
echo " + creata tabella requisito_actions\n";
}
echo "Migrazione 067 — residuo + requisito_actions OK. Prossima mig=068.\n";
+18
View File
@@ -103,6 +103,24 @@ define('ANTHROPIC_API_KEY', Env::get('ANTHROPIC_API_KEY', ''));
define('ANTHROPIC_MODEL', Env::get('ANTHROPIC_MODEL', 'claude-sonnet-4-5-20250929')); define('ANTHROPIC_MODEL', Env::get('ANTHROPIC_MODEL', 'claude-sonnet-4-5-20250929'));
define('ANTHROPIC_MAX_TOKENS', Env::int('ANTHROPIC_MAX_TOKENS', 4096)); define('ANTHROPIC_MAX_TOKENS', Env::int('ANTHROPIC_MAX_TOKENS', 4096));
// ═══════════════════════════════════════════════════════════════════════════
// VOCEA (Whistleblowing zero-knowledge — integrazione, DISABILITATA di default)
// ═══════════════════════════════════════════════════════════════════════════
// SCAFFOLD DORMIENTE: con VOCEA_ENABLED=false (default) VoceaService non effettua
// nessuna chiamata di rete. Attivazione a MS live: applicare migrate_063 +
// VOCEA_ENABLED=true + VOCEA_API_KEY (vault tier1__nis2-app__vocea__*) +
// slug del canale su organizations.vocea_tenant_slug (status='active').
define('VOCEA_ENABLED', filter_var(Env::get('VOCEA_ENABLED', 'false'), FILTER_VALIDATE_BOOLEAN));
define('VOCEA_BASE_URL', Env::get('VOCEA_BASE_URL', 'https://api.vocea.cloud/api/wb/v1')); // API integratori (X-API-Key)
define('VOCEA_API_KEY', Env::get('VOCEA_API_KEY', '')); // wbk_... per-tenant (da vault)
define('VOCEA_PORTAL_URL', Env::get('VOCEA_PORTAL_URL', 'https://app.vocea.cloud')); // portale embed via wb-link.js
// ═══════════════════════════════════════════════════════════════════════════
// VOICE / TTS (ARIA voce naturale — proxy same-origin a nexus-voice-ms AgileHub)
// ═══════════════════════════════════════════════════════════════════════════
define('VOICE_MS_URL', Env::get('VOICE_MS_URL', 'http://172.21.0.1:4215/tts/speak')); // gateway nis2-network → host:4215
define('TTS_VOICE_ID', Env::get('TTS_VOICE_ID', 'EXAVITQu4vr4xnSDxMaL')); // voce "Sarah" (come AllTax/TRPG)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// CERTISOURCE (atti-service.php) // CERTISOURCE (atti-service.php)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
+114 -1
View File
@@ -39,12 +39,51 @@ class AiController extends BaseController
} }
$user = $this->getCurrentUser() ?? []; $user = $this->getCurrentUser() ?? [];
// Pagina corrente (facoltativa) inviata dal frontend, per dare ad ARIA
// contesto sulla schermata da cui l'utente sta scrivendo (ticket #424).
$page = mb_substr(trim((string) $this->getParam('page', '')), 0, 120);
// pageId canonico + testo dell'help "?" della pagina (allineamento ARIA↔Help).
$pageId = mb_substr(trim((string) $this->getParam('page_id', '')), 0, 40);
$pageHelp = mb_substr(trim((string) $this->getParam('page_help', '')), 0, 2500);
// Org attiva per lo snapshot dati di ARIA. ask() chiama requireAuth() ma NON
// requireOrgAccess(), quindi getCurrentOrgId() resta null e org_data_ok sarebbe
// sempre falso (ARIA cieca sui dati org). Risolviamo a mano: header
// X-Organization-Id → param org_id → currentOrgId → org primaria dell'utente.
// Lo spoofing è neutralizzato dal controllo membership qui sotto.
$orgId = (int) ($_SERVER['HTTP_X_ORGANIZATION_ID'] ?? $this->getParam('org_id') ?? 0);
if ($orgId <= 0) $orgId = (int) ($this->getCurrentOrgId() ?? 0);
if ($orgId <= 0) {
$primary = Database::fetchOne(
'SELECT organization_id FROM user_organizations WHERE user_id = ? ORDER BY is_primary DESC, id ASC LIMIT 1',
[$userId]
);
$orgId = (int) ($primary['organization_id'] ?? 0);
}
$userContext = [ $userContext = [
'user_id' => $userId, 'user_id' => $userId,
'organization_id' => $this->getCurrentOrgId(), // può essere null 'organization_id' => $orgId ?: null,
'consulting_firm_id' => $user['consulting_firm_id'] ?? null, 'consulting_firm_id' => $user['consulting_firm_id'] ?? null,
'page' => $page,
'page_id' => $pageId,
'page_help' => $pageHelp,
]; ];
// Membership verificata → ARIA può iniettare lo snapshot DATI dell'org
// (anti-spoof X-Organization-Id; super_admin bypassa, come da modello ruoli).
$orgId = (int) ($userContext['organization_id'] ?? 0);
$userContext['org_data_ok'] = false;
if ($orgId > 0) {
if (($user['role'] ?? '') === 'super_admin') {
$userContext['org_data_ok'] = true;
} else {
$member = Database::fetchOne(
'SELECT 1 FROM user_organizations WHERE user_id = ? AND organization_id = ?',
[$userId, $orgId]
);
$userContext['org_data_ok'] = (bool) $member;
}
}
try { try {
$aiService = new AIService(); $aiService = new AIService();
$result = $aiService->askWithRag($question, $userContext); $result = $aiService->askWithRag($question, $userContext);
@@ -72,4 +111,78 @@ class AiController extends BaseController
$this->jsonError('Assistente AI non disponibile in questo momento', 503, 'AI_UNAVAILABLE'); $this->jsonError('Assistente AI non disponibile in questo momento', 503, 'AI_UNAVAILABLE');
} }
} }
/**
* POST /api/ai/tts — Voce naturale di ARIA.
* Proxy same-origin verso nexus-voice-ms (ElevenLabs): il voice-ms non espone CORS
* per l'origin NIS2 → il browser non può chiamarlo diretto. Cache MP3 per hash del
* testo (stesse frasi → niente costo ripetuto). Stream audio/mpeg.
*/
public function tts(): void
{
$this->requireAuth();
$text = trim((string) $this->getParam('text', ''));
if ($text === '') { $this->jsonError('Testo mancante', 422, 'TTS_EMPTY'); }
if (mb_strlen($text) > 800) { $text = mb_substr($text, 0, 800); }
$lang = substr(strtolower(preg_replace('/[^a-z]/', '', (string) $this->getParam('lang', 'it')) ?: 'it'), 0, 5) ?: 'it';
$voice = (string) $this->getParam('voice_id', defined('TTS_VOICE_ID') ? TTS_VOICE_ID : 'EXAVITQu4vr4xnSDxMaL');
if (!preg_match('/^[A-Za-z0-9]{8,40}$/', $voice)) {
$voice = defined('TTS_VOICE_ID') ? TTS_VOICE_ID : 'EXAVITQu4vr4xnSDxMaL';
}
$cacheDir = sys_get_temp_dir() . '/nis2-tts-cache';
$cacheFile = $cacheDir . '/' . hash('sha256', $voice . '|' . $lang . '|' . $text) . '.mp3';
if (is_file($cacheFile) && filesize($cacheFile) > 256) {
$this->streamMp3((string) file_get_contents($cacheFile), 'HIT');
return;
}
$payload = json_encode([
'text' => $text,
'lang' => $lang,
'voice_id' => $voice,
'model' => 'eleven_turbo_v2_5',
'stability' => 0.5,
'similarity_boost' => 0.75,
'style' => 0.0,
'output_format' => 'mp3_44100_128',
]);
$url = defined('VOICE_MS_URL') ? VOICE_MS_URL : 'http://172.21.0.1:4215/tts/speak';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 6,
CURLOPT_TIMEOUT => 30,
]);
$audio = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$ctype = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if ($code !== 200 || !$audio || strlen($audio) < 256 || stripos($ctype, 'audio') === false) {
$this->jsonError('Sintesi vocale non disponibile', 502, 'TTS_UPSTREAM');
}
if (!is_dir($cacheDir)) { @mkdir($cacheDir, 0775, true); }
@file_put_contents($cacheFile . '.tmp', $audio);
@rename($cacheFile . '.tmp', $cacheFile);
$this->streamMp3($audio, 'MISS');
}
private function streamMp3(string $audio, string $cache): void
{
header('Content-Type: audio/mpeg');
header('Content-Length: ' . strlen($audio));
header('Cache-Control: private, max-age=86400');
header('X-TTS-Cache: ' . $cache);
echo $audio;
exit;
}
} }
@@ -167,6 +167,10 @@ class AssetController extends BaseController
'relevance_assessed_at' => date('Y-m-d H:i:s'), 'relevance_assessed_at' => date('Y-m-d H:i:s'),
'relevance_assessed_by' => $userId, 'relevance_assessed_by' => $userId,
]; ];
// Dipendenze scoperte dal connettore → popolano la Mappa Dipendenze.
if (isset($a['dependencies']) && is_array($a['dependencies'])) {
$row['dependencies'] = json_encode(array_values($a['dependencies']), JSON_UNESCAPED_UNICODE);
}
try { try {
$existing = $extRef !== null $existing = $extRef !== null
@@ -340,6 +344,59 @@ class AssetController extends BaseController
$this->jsonSuccess(['id' => $id, 'voce_code' => $voce, 'label' => $label], 'Sottoclasse aggiunta', 201); $this->jsonSuccess(['id' => $id, 'voce_code' => $voce, 'label' => $label], 'Sottoclasse aggiunta', 201);
} }
/**
* PUT /api/assets/subclasses/{id} { label* }
* Rinomina una sottoclasse ORGANIZZATIVA dell'org corrente. Le sottoclassi di
* sistema (organization_id NULL) NON sono modificabili dall'utente.
*/
public function updateSubclass(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$body = $this->getJsonBody();
$label = trim((string) ($body['label'] ?? ''));
if ($label === '' || mb_strlen($label) > 120) {
$this->jsonError('Etichetta sottoclasse obbligatoria (max 120 caratteri)', 422, 'INVALID_LABEL');
}
$orgId = $this->getCurrentOrgId();
$row = Database::fetchOne(
'SELECT id, voce_code FROM inventory_subclassi WHERE id = ? AND organization_id = ?',
[$id, $orgId]
);
if (!$row) {
$this->jsonError('Sottoclasse non trovata o non modificabile (le voci di sistema non si modificano)', 404, 'SUBCLASS_NOT_FOUND');
}
$dup = Database::fetchOne(
'SELECT id FROM inventory_subclassi WHERE voce_code = ? AND label = ? AND (organization_id = ? OR organization_id IS NULL) AND id <> ?',
[$row['voce_code'], $label, $orgId, $id]
);
if ($dup) { $this->jsonError('Sottoclasse già esistente', 409, 'DUPLICATE'); }
Database::update('inventory_subclassi', ['label' => $label], 'id = ? AND organization_id = ?', [$id, $orgId]);
$this->jsonSuccess(['id' => $id, 'label' => $label], 'Sottoclasse aggiornata');
}
/**
* DELETE /api/assets/subclasses/{id}
* Rimuove una sottoclasse ORGANIZZATIVA dell'org corrente. Gli asset che la
* usano restano senza sottoclasse (FK ON DELETE SET NULL su assets.subclass_id).
* Le sottoclassi di sistema (organization_id NULL) NON sono eliminabili.
*/
public function deleteSubclass(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$orgId = $this->getCurrentOrgId();
$row = Database::fetchOne(
'SELECT id FROM inventory_subclassi WHERE id = ? AND organization_id = ?',
[$id, $orgId]
);
if (!$row) {
$this->jsonError('Sottoclasse non trovata o non eliminabile (le voci di sistema non si rimuovono)', 404, 'SUBCLASS_NOT_FOUND');
}
$used = Database::fetchOne('SELECT COUNT(*) AS c FROM assets WHERE subclass_id = ?', [$id]);
$usedCount = (int) ($used['c'] ?? 0);
Database::delete('inventory_subclassi', 'id = ? AND organization_id = ?', [$id, $orgId]);
$this->jsonSuccess(['id' => $id, 'unlinked_assets' => $usedCount], 'Sottoclasse rimossa');
}
public function delete(int $id): void public function delete(int $id): void
{ {
$this->requireOrgRole(['org_admin']); $this->requireOrgRole(['org_admin']);
@@ -0,0 +1,161 @@
<?php
require_once __DIR__ . '/BaseController.php';
/**
* ConsultantController — Cruscotto Studio (consulente / società di consulenza).
* Vista d'insieme su TUTTE le aziende gestite dall'utente: compliance, rischi,
* incidenti e SCADENZE GENERALI aggregate. Read-only, org-scoping = visibilità
* dell'utente (super_admin = tutte le org attive; altri = le proprie membership).
*/
class ConsultantController extends BaseController
{
/**
* GET /api/consultant/portfolio
*/
public function portfolio(): void
{
$this->requireAuth();
$user = $this->getCurrentUser() ?? [];
$uid = (int) $this->getCurrentUserId();
// Aziende visibili all'utente (stessa regola di OrganizationController::list):
// super_admin = tutte; consulente = clienti del proprio studio (consulting_firm_id)
// UNION le proprie membership; altri = solo le proprie membership.
$firmId = (int) ($user['consulting_firm_id'] ?? 0);
if (($user['role'] ?? '') === 'super_admin') {
$orgs = Database::fetchAll(
"SELECT id, name, entity_type, voluntary_compliance, sector
FROM organizations WHERE is_active = 1 ORDER BY name LIMIT 200"
);
} elseif (($user['role'] ?? '') === 'consultant' && $firmId > 0) {
$orgs = Database::fetchAll(
"SELECT DISTINCT o.id, o.name, o.entity_type, o.voluntary_compliance, o.sector
FROM organizations o
LEFT JOIN user_organizations uo ON uo.organization_id = o.id AND uo.user_id = ?
WHERE o.is_active = 1 AND (o.consulting_firm_id = ? OR uo.user_id IS NOT NULL)
ORDER BY o.name LIMIT 200",
[$uid, $firmId]
);
} else {
$orgs = Database::fetchAll(
"SELECT o.id, o.name, o.entity_type, o.voluntary_compliance, o.sector
FROM organizations o
JOIN user_organizations uo ON uo.organization_id = o.id
WHERE uo.user_id = ? AND o.is_active = 1 ORDER BY o.name LIMIT 200",
[$uid]
);
}
$now = date('Y-m-d H:i:s');
$companies = [];
$allDeadlines = [];
$sumScore = 0; $nScore = 0; $overdueTotal = 0;
foreach ($orgs as $o) {
$oid = (int) $o['id'];
$asmt = Database::fetchOne(
"SELECT overall_score FROM assessments
WHERE organization_id = ? AND status = 'completed'
ORDER BY completed_at DESC LIMIT 1", [$oid]);
$score = ($asmt && $asmt['overall_score'] !== null) ? (int) round((float) $asmt['overall_score']) : null;
$risks = (int) (Database::fetchOne(
"SELECT COUNT(*) c FROM risks WHERE organization_id = ? AND status <> 'closed'", [$oid])['c'] ?? 0);
$incs = (int) (Database::fetchOne(
"SELECT COUNT(*) c FROM incidents WHERE organization_id = ? AND status NOT IN ('closed','post_mortem')", [$oid])['c'] ?? 0);
$dls = $this->collectDeadlines($oid);
$overdue = 0;
foreach ($dls as $d) {
$d['org_id'] = $oid;
$d['org_name'] = $o['name'];
$d['overdue'] = ($d['due_date'] < $now);
if ($d['overdue']) { $overdue++; }
$allDeadlines[] = $d;
}
if ($score !== null) { $sumScore += $score; $nScore++; }
$overdueTotal += $overdue;
$companies[] = [
'id' => $oid,
'name' => $o['name'],
'entity_type' => $o['entity_type'],
'voluntary_compliance' => (int) $o['voluntary_compliance'],
'sector' => $o['sector'],
'compliance_score' => $score,
'open_risks' => $risks,
'open_incidents' => $incs,
'deadlines_overdue' => $overdue,
'deadlines_upcoming' => count($dls) - $overdue,
];
}
usort($allDeadlines, fn($a, $b) => strcmp((string) $a['due_date'], (string) $b['due_date']));
$this->jsonSuccess([
'companies' => $companies,
'deadlines' => $allDeadlines,
'kpis' => [
'total_companies' => count($companies),
'avg_compliance' => $nScore ? (int) round($sumScore / $nScore) : null,
'overdue_total' => $overdueTotal,
'upcoming_total' => count($allDeadlines) - $overdueTotal,
],
]);
}
/**
* Scadenze "aperte" di una org (incidenti CSIRT, policy, trattamenti rischio,
* formazione) con orizzonte a +30 giorni ma includendo anche gli scaduti (past-due),
* così il cruscotto distingue "in ritardo" da "in arrivo". Fonti coerenti con
* DashboardController::deadlines().
*/
private function collectDeadlines(int $orgId): array
{
$out = [];
$horizon = "DATE_ADD(NOW(), INTERVAL 30 DAY)";
$inc = Database::fetchAll(
"SELECT id, title, early_warning_due, early_warning_sent_at, notification_due,
notification_sent_at, final_report_due, final_report_sent_at
FROM incidents
WHERE organization_id = ? AND is_significant = 1 AND status NOT IN ('closed','post_mortem')",
[$orgId]);
foreach ($inc as $i) {
if ($i['early_warning_due'] && !$i['early_warning_sent_at'])
$out[] = ['type'=>'incident_early_warning','title'=>"Early Warning: {$i['title']}",'due_date'=>$i['early_warning_due'],'severity'=>'critical','entity_type'=>'incident','entity_id'=>(int)$i['id']];
if ($i['notification_due'] && !$i['notification_sent_at'])
$out[] = ['type'=>'incident_notification','title'=>"Notifica CSIRT: {$i['title']}",'due_date'=>$i['notification_due'],'severity'=>'high','entity_type'=>'incident','entity_id'=>(int)$i['id']];
if ($i['final_report_due'] && !$i['final_report_sent_at'])
$out[] = ['type'=>'incident_final_report','title'=>"Report finale: {$i['title']}",'due_date'=>$i['final_report_due'],'severity'=>'medium','entity_type'=>'incident','entity_id'=>(int)$i['id']];
}
foreach (Database::fetchAll(
"SELECT id, title, next_review_date FROM policies
WHERE organization_id = ? AND next_review_date IS NOT NULL
AND next_review_date <= $horizon AND status NOT IN ('archived')", [$orgId]) as $p) {
$out[] = ['type'=>'policy_review','title'=>"Revisione policy: {$p['title']}",'due_date'=>$p['next_review_date'],'severity'=>'medium','entity_type'=>'policy','entity_id'=>(int)$p['id']];
}
foreach (Database::fetchAll(
"SELECT rt.id, rt.due_date, r.title AS risk_title FROM risk_treatments rt
JOIN risks r ON r.id = rt.risk_id
WHERE r.organization_id = ? AND rt.status IN ('planned','in_progress')
AND rt.due_date IS NOT NULL AND rt.due_date <= $horizon", [$orgId]) as $t) {
$out[] = ['type'=>'risk_treatment','title'=>"Trattamento rischio: {$t['risk_title']}",'due_date'=>$t['due_date'],'severity'=>'medium','entity_type'=>'risk_treatment','entity_id'=>(int)$t['id']];
}
foreach (Database::fetchAll(
"SELECT ta.id, tc.title, ta.due_date FROM training_assignments ta
JOIN training_courses tc ON tc.id = ta.course_id
WHERE ta.organization_id = ? AND ta.status IN ('assigned','in_progress')
AND ta.due_date IS NOT NULL AND ta.due_date <= $horizon", [$orgId]) as $tr) {
$out[] = ['type'=>'training_due','title'=>"Formazione: {$tr['title']}",'due_date'=>$tr['due_date'],'severity'=>'low','entity_type'=>'training','entity_id'=>(int)$tr['id']];
}
return $out;
}
}
@@ -0,0 +1,180 @@
<?php
/**
* NIS2 Agile - DiscoveryConnectorController
*
* Connettori di discovery per-organizzazione (più connettori per azienda).
* Ogni connettore ha una api_key dedicata (scope ingest:assets) che l'agente esterno
* usa per auto-popolare Inventario + dipendenze + flussi di rete (ID.AM-03) via
* POST /api/services/assets-ingest. Lo storico run è in discovery_runs.
*
* Endpoint (org_admin):
* GET /api/discovery-connectors lista
* POST /api/discovery-connectors crea (+ emette api_key, mostrata 1 volta)
* GET /api/discovery-connectors/{id} dettaglio + ultimi run
* PUT /api/discovery-connectors/{id} aggiorna (name/config/schedule/status)
* DELETE /api/discovery-connectors/{id} elimina (+ disattiva la sua api_key)
* POST /api/discovery-connectors/{id}/rotateKey rigenera api_key
*/
require_once __DIR__ . '/BaseController.php';
class DiscoveryConnectorController extends BaseController
{
private const TYPES = ['network', 'aws', 'azure', 'gcp', 'cmdb', 'agent', 'custom'];
private const SCHEDULES = ['manual', 'hourly', 'daily', 'weekly'];
private const STATUSES = ['active', 'paused', 'error'];
public function list(): void
{
$this->requireOrgRole(['org_admin']);
$orgId = $this->getCurrentOrgId();
$rows = Database::fetchAll(
"SELECT dc.id, dc.name, dc.connector_type, dc.config, dc.status, dc.schedule,
dc.last_run_at, dc.last_status, dc.last_discovered, dc.last_message, dc.created_at,
ak.key_prefix
FROM discovery_connectors dc
LEFT JOIN api_keys ak ON ak.id = dc.api_key_id
WHERE dc.organization_id = ?
ORDER BY dc.created_at DESC",
[$orgId]
);
foreach ($rows as &$r) { $r['config'] = json_decode($r['config'] ?? 'null', true); }
$this->jsonSuccess(['connectors' => $rows, 'total' => count($rows), 'types' => self::TYPES]);
}
public function create(): void
{
$this->requireOrgRole(['org_admin']);
$this->validateRequired(['name']);
$orgId = $this->getCurrentOrgId();
$userId = $this->getCurrentUserId();
$name = trim((string) $this->getParam('name'));
$type = $this->getParam('connector_type', 'network');
if (!in_array($type, self::TYPES, true)) $type = 'network';
$schedule = $this->getParam('schedule', 'manual');
if (!in_array($schedule, self::SCHEDULES, true)) $schedule = 'manual';
$config = $this->getParam('config');
$configJson = $config !== null ? json_encode($config, JSON_UNESCAPED_UNICODE) : null;
if (Database::fetchOne('SELECT id FROM discovery_connectors WHERE organization_id=? AND name=?', [$orgId, $name])) {
$this->jsonError('Esiste già un connettore con questo nome', 409, 'DUPLICATE');
}
[$keyId, $rawKey, $prefix] = $this->issueIngestKey($orgId, $userId, "discovery: {$name}");
$id = Database::insert('discovery_connectors', [
'organization_id' => $orgId,
'name' => $name,
'connector_type' => $type,
'config' => $configJson,
'api_key_id' => $keyId,
'status' => 'active',
'schedule' => $schedule,
'created_by' => $userId,
]);
$this->logAudit('discovery_connector_created', 'discovery_connector', $id, ['name' => $name, 'type' => $type]);
$this->jsonSuccess([
'id' => $id,
'name' => $name,
'connector_type' => $type,
'api_key' => $rawKey, // mostrata UNA sola volta
'api_key_prefix' => $prefix,
'ingest_url' => 'https://nis2.agile.software/api/services/assets-ingest',
'note' => "Copia ORA la chiave: non sarà più visibile. L'agente la usa nell'header X-API-Key.",
], 'Connettore creato', 201);
}
public function get(int $id): void
{
$this->requireOrgRole(['org_admin']);
$orgId = $this->getCurrentOrgId();
$c = Database::fetchOne(
"SELECT dc.*, ak.key_prefix FROM discovery_connectors dc
LEFT JOIN api_keys ak ON ak.id = dc.api_key_id
WHERE dc.id = ? AND dc.organization_id = ?",
[$id, $orgId]
);
if (!$c) $this->jsonError('Connettore non trovato', 404, 'NOT_FOUND');
$c['config'] = json_decode($c['config'] ?? 'null', true);
unset($c['api_key_id']);
$c['runs'] = Database::fetchAll(
'SELECT id, started_at, finished_at, status, discovered_assets, discovered_flows, message
FROM discovery_runs WHERE connector_id = ? ORDER BY started_at DESC LIMIT 20',
[$id]
);
$this->jsonSuccess($c);
}
public function update(int $id): void
{
$this->requireOrgRole(['org_admin']);
$orgId = $this->getCurrentOrgId();
$c = Database::fetchOne('SELECT * FROM discovery_connectors WHERE id=? AND organization_id=?', [$id, $orgId]);
if (!$c) $this->jsonError('Connettore non trovato', 404, 'NOT_FOUND');
$updates = [];
if ($this->hasParam('name')) $updates['name'] = trim((string) $this->getParam('name'));
if ($this->hasParam('connector_type')) { $t = $this->getParam('connector_type'); if (in_array($t, self::TYPES, true)) $updates['connector_type'] = $t; }
if ($this->hasParam('schedule')) { $s = $this->getParam('schedule'); if (in_array($s, self::SCHEDULES, true)) $updates['schedule'] = $s; }
if ($this->hasParam('status')) { $st = $this->getParam('status'); if (in_array($st, self::STATUSES, true)) $updates['status'] = $st; }
if ($this->hasParam('config')) $updates['config'] = json_encode($this->getParam('config'), JSON_UNESCAPED_UNICODE);
if (!$updates) { $this->jsonSuccess(null, 'Nessuna modifica'); return; }
Database::query(
'UPDATE discovery_connectors SET ' . implode(', ', array_map(fn($k) => "{$k} = ?", array_keys($updates))) . ', updated_at = NOW() WHERE id = ?',
array_merge(array_values($updates), [$id])
);
$this->logAudit('discovery_connector_updated', 'discovery_connector', $id, $updates);
$this->jsonSuccess(null, 'Connettore aggiornato');
}
public function delete(int $id): void
{
$this->requireOrgRole(['org_admin']);
$orgId = $this->getCurrentOrgId();
$c = Database::fetchOne('SELECT * FROM discovery_connectors WHERE id=? AND organization_id=?', [$id, $orgId]);
if (!$c) $this->jsonError('Connettore non trovato', 404, 'NOT_FOUND');
if (!empty($c['api_key_id'])) {
Database::query('UPDATE api_keys SET is_active=0 WHERE id=? AND organization_id=?', [$c['api_key_id'], $orgId]);
}
Database::query('DELETE FROM discovery_runs WHERE connector_id=?', [$id]);
Database::query('DELETE FROM discovery_connectors WHERE id=?', [$id]);
$this->logAudit('discovery_connector_deleted', 'discovery_connector', $id, ['name' => $c['name']]);
$this->jsonSuccess(null, 'Connettore eliminato');
}
public function rotateKey(int $id): void
{
$this->requireOrgRole(['org_admin']);
$orgId = $this->getCurrentOrgId();
$userId = $this->getCurrentUserId();
$c = Database::fetchOne('SELECT * FROM discovery_connectors WHERE id=? AND organization_id=?', [$id, $orgId]);
if (!$c) $this->jsonError('Connettore non trovato', 404, 'NOT_FOUND');
if (!empty($c['api_key_id'])) {
Database::query('UPDATE api_keys SET is_active=0 WHERE id=?', [$c['api_key_id']]);
}
[$keyId, $rawKey, $prefix] = $this->issueIngestKey($orgId, $userId, "discovery: {$c['name']}");
Database::query('UPDATE discovery_connectors SET api_key_id=?, updated_at=NOW() WHERE id=?', [$keyId, $id]);
$this->logAudit('discovery_connector_key_rotated', 'discovery_connector', $id, []);
$this->jsonSuccess(['api_key' => $rawKey, 'api_key_prefix' => $prefix], 'Chiave rigenerata (copia ora)');
}
/** Emette una api_key con scope ingest:assets. @return [id, rawKey, prefix] */
private function issueIngestKey(int $orgId, int $userId, string $name): array
{
$rawKey = 'nis2_' . bin2hex(random_bytes(16));
$prefix = substr($rawKey, 0, 12);
$keyId = Database::insert('api_keys', [
'organization_id' => $orgId,
'created_by' => $userId,
'name' => substr($name, 0, 100),
'key_prefix' => $prefix,
'key_hash' => hash('sha256', $rawKey),
'scopes' => json_encode(['ingest:assets']),
'is_active' => 1,
]);
return [$keyId, $rawKey, $prefix];
}
}
@@ -199,21 +199,37 @@ class FrameworkController extends BaseController
$note = array_key_exists('note', $body) $note = array_key_exists('note', $body)
? ($body['note'] !== '' ? (string) $body['note'] : null) : null; ? ($body['note'] !== '' ? (string) $body['note'] : null) : null;
// Probabilita/impatto 1-5 (opzione B ticket #500, matrice 5x5). null = non valutato.
$scale = static function ($v) {
if ($v === null || $v === '') return null;
$n = (int) $v;
return ($n >= 1 && $n <= 5) ? $n : null;
};
$likelihood = array_key_exists('likelihood', $body) ? $scale($body['likelihood']) : null;
$impact = array_key_exists('impact', $body) ? $scale($body['impact']) : null;
// Rischio RESIDUO (mig 067) — dopo il trattamento. Il client invia sempre la riga intera.
$resLikelihood = array_key_exists('residual_likelihood', $body) ? $scale($body['residual_likelihood']) : null;
$resImpact = array_key_exists('residual_impact', $body) ? $scale($body['residual_impact']) : null;
// upsert org-scoped (UNIQUE org+requisito) // upsert org-scoped (UNIQUE org+requisito)
Database::query( Database::query(
'INSERT INTO org_requisito_state 'INSERT INTO org_requisito_state
(organization_id, requisito_id, stato, valutazione_rischio, note, updated_by) (organization_id, requisito_id, stato, valutazione_rischio, note, likelihood, impact, residual_likelihood, residual_impact, updated_by)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
stato = VALUES(stato), stato = VALUES(stato),
valutazione_rischio = VALUES(valutazione_rischio), valutazione_rischio = VALUES(valutazione_rischio),
note = VALUES(note), note = VALUES(note),
likelihood = VALUES(likelihood),
impact = VALUES(impact),
residual_likelihood = VALUES(residual_likelihood),
residual_impact = VALUES(residual_impact),
updated_by = VALUES(updated_by)', updated_by = VALUES(updated_by)',
[$orgId, $reqId, $stato ?? 'da_valutare', $valutazione, $note, $this->getCurrentUserId()] [$orgId, $reqId, $stato ?? 'da_valutare', $valutazione, $note, $likelihood, $impact, $resLikelihood, $resImpact, $this->getCurrentUserId()]
); );
$row = Database::fetchOne( $row = Database::fetchOne(
'SELECT requisito_id, stato, valutazione_rischio, note, updated_at 'SELECT requisito_id, stato, valutazione_rischio, note, likelihood, impact, residual_likelihood, residual_impact, updated_at
FROM org_requisito_state WHERE organization_id = ? AND requisito_id = ?', FROM org_requisito_state WHERE organization_id = ? AND requisito_id = ?',
[$orgId, $reqId] [$orgId, $reqId]
); );
@@ -222,6 +238,10 @@ class FrameworkController extends BaseController
'stato' => $row['stato'], 'stato' => $row['stato'],
'valutazione_rischio' => $row['valutazione_rischio'], 'valutazione_rischio' => $row['valutazione_rischio'],
'note' => $row['note'], 'note' => $row['note'],
'likelihood' => $row['likelihood'] !== null ? (int) $row['likelihood'] : null,
'impact' => $row['impact'] !== null ? (int) $row['impact'] : null,
'residual_likelihood' => $row['residual_likelihood'] !== null ? (int) $row['residual_likelihood'] : null,
'residual_impact' => $row['residual_impact'] !== null ? (int) $row['residual_impact'] : null,
'updated_at' => $row['updated_at'], 'updated_at' => $row['updated_at'],
], 'Valutazione salvata'); ], 'Valutazione salvata');
} }
@@ -0,0 +1,114 @@
<?php
/**
* NIS2 Agile - InstitutionalDocsController
*
* Documenti istituzionali per-organizzazione (card in dashboard.html).
* Ticket #499 (Simon Fattori, super_admin):
* 1. Ad ogni documento va inserita una DESCRIZIONE obbligatoria a opera dell'utente.
* 2. Oltre al tasto "aggiungi voce" ci sono 5 voci standard sempre presenti:
* Codice Etico, Mission, Vision, Statuto, Piano Sanzionatorio.
*
* Le 5 voci standard sono renderizzate sempre lato UI (STANDARDS); il backend salva
* una riga per (organization_id, doc_key) quando l'utente le compila.
*
* Endpoint:
* GET /api/institutional-docs/list lista (voci compilate + elenco standard)
* POST /api/institutional-docs/save upsert (title+description obbligatori)
* DELETE /api/institutional-docs/{id} elimina voce (org-scoped)
*/
require_once __DIR__ . '/BaseController.php';
class InstitutionalDocsController extends BaseController
{
/** Le 5 voci standard sempre presenti (richiesta punto 2). */
private const STANDARDS = [
['key' => 'codice_etico', 'label' => 'Codice Etico'],
['key' => 'mission', 'label' => 'Mission'],
['key' => 'vision', 'label' => 'Vision'],
['key' => 'statuto', 'label' => 'Statuto'],
['key' => 'piano_sanzionatorio', 'label' => 'Piano Sanzionatorio'],
];
public function list(): void
{
$this->requireAuth();
// BUGFIX (#499): usare requireAuth+getCurrentOrgId dava sempre NO_ORG (currentOrgId
// e' popolato solo da requireOrgAccess) → le 5 voci standard non comparivano MAI.
// Ora risolviamo l'org dall'header e restituiamo SEMPRE le 5 voci standard;
// i documenti salvati si aggiungono solo se un'org e' selezionata.
$orgId = $this->resolveOrgId();
$rows = $orgId ? Database::fetchAll(
"SELECT id, doc_key, title, description, file_url, is_standard, sort_order, updated_at
FROM institutional_documents
WHERE organization_id = ?
ORDER BY is_standard DESC, sort_order ASC, id ASC",
[$orgId]
) : [];
$this->jsonSuccess(['docs' => $rows, 'standards' => self::STANDARDS]);
}
public function save(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$this->validateRequired(['title', 'description']);
$orgId = $this->getCurrentOrgId();
$userId = $this->getCurrentUserId();
$title = trim((string) $this->getParam('title'));
$desc = trim((string) $this->getParam('description'));
if ($title === '' || $desc === '') {
$this->jsonError('Titolo e descrizione sono obbligatori', 422, 'MISSING_FIELDS');
}
$fileUrl = $this->getParam('file_url');
$fileUrl = ($fileUrl !== null && trim((string) $fileUrl) !== '') ? trim((string) $fileUrl) : null;
$id = (int) ($this->getParam('id') ?? 0);
$docKey = $this->getParam('doc_key');
$stdKeys = array_column(self::STANDARDS, 'key');
$isStandard = ($docKey !== null && in_array($docKey, $stdKeys, true)) ? 1 : 0;
if (!$isStandard) { $docKey = null; } // le voci custom non hanno doc_key
// Trova riga esistente: per id, oppure per (org, doc_key) se voce standard.
$existing = null;
if ($id > 0) {
$existing = Database::fetchOne(
'SELECT id FROM institutional_documents WHERE id=? AND organization_id=?', [$id, $orgId]);
if (!$existing) { $this->jsonError('Documento non trovato', 404, 'NOT_FOUND'); }
} elseif ($isStandard) {
$existing = Database::fetchOne(
'SELECT id FROM institutional_documents WHERE organization_id=? AND doc_key=?', [$orgId, $docKey]);
}
if ($existing) {
Database::update('institutional_documents', [
'title' => $title,
'description' => $desc,
'file_url' => $fileUrl,
], 'id=? AND organization_id=?', [(int) $existing['id'], $orgId]);
$savedId = (int) $existing['id'];
} else {
$savedId = Database::insert('institutional_documents', [
'organization_id' => $orgId,
'doc_key' => $docKey,
'title' => $title,
'description' => $desc,
'file_url' => $fileUrl,
'is_standard' => $isStandard,
'created_by' => $userId,
]);
}
$this->jsonSuccess(['id' => $savedId], 'Documento salvato');
}
public function delete(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$orgId = $this->getCurrentOrgId();
$row = Database::fetchOne(
'SELECT id FROM institutional_documents WHERE id=? AND organization_id=?', [$id, $orgId]);
if (!$row) { $this->jsonError('Documento non trovato', 404, 'NOT_FOUND'); }
Database::delete('institutional_documents', 'id=? AND organization_id=?', [$id, $orgId]);
$this->jsonSuccess(null, 'Documento eliminato');
}
}
+15 -4
View File
@@ -316,11 +316,22 @@ class OrgRoleController extends BaseController
$roots[] = $r; $roots[] = $r;
} }
} }
$attach = function (array $node) use (&$attach, $childrenByParent) { // Guardia anti-ciclo: se parent_role_id formasse un ciclo (dato legacy/corrotto),
$kids = $childrenByParent[(int) $node['id']] ?? []; // la ricorsione andrebbe in loop infinito → hang del backend e "blocco" della pagina.
$node['children'] = array_map($attach, $kids); // Portiamo la catena di antenati e non ridiscendiamo su un id già visto.
$attach = function (array $node, array $ancestry) use (&$attach, $childrenByParent) {
$id = (int) $node['id'];
$ancestry[$id] = true;
$children = [];
foreach ($childrenByParent[$id] ?? [] as $kid) {
if (isset($ancestry[(int) $kid['id']])) {
continue; // ciclo: interrompi questo ramo invece di ricorrere all'infinito
}
$children[] = $attach($kid, $ancestry);
}
$node['children'] = $children;
return $node; return $node;
}; };
return array_map($attach, $roots); return array_map(static fn(array $r) => $attach($r, []), $roots);
} }
} }
@@ -124,8 +124,20 @@ class OrganizationController extends BaseController
{ {
$this->requireAuth(); $this->requireAuth();
$firmId = (int) ($this->currentUser['consulting_firm_id'] ?? 0);
if ($this->currentUser['role'] === 'super_admin') { if ($this->currentUser['role'] === 'super_admin') {
$orgs = Database::fetchAll('SELECT * FROM organizations WHERE is_active = 1 ORDER BY name'); $orgs = Database::fetchAll('SELECT * FROM organizations WHERE is_active = 1 ORDER BY name');
} elseif ($this->currentUser['role'] === 'consultant' && $firmId > 0) {
// Consulente: vede TUTTI i clienti del proprio studio (organizations.consulting_firm_id
// = il suo firm) UNION le org di cui è membro diretto. Non l'intero DB (quello è super_admin).
$orgs = Database::fetchAll(
'SELECT DISTINCT o.*, uo.role AS user_role, uo.is_primary
FROM organizations o
LEFT JOIN user_organizations uo ON uo.organization_id = o.id AND uo.user_id = ?
WHERE o.is_active = 1 AND (o.consulting_firm_id = ? OR uo.user_id IS NOT NULL)
ORDER BY uo.is_primary DESC, o.name',
[$this->getCurrentUserId(), $firmId]
);
} else { } else {
$orgs = Database::fetchAll( $orgs = Database::fetchAll(
'SELECT o.*, uo.role as user_role, uo.is_primary 'SELECT o.*, uo.role as user_role, uo.is_primary
+138
View File
@@ -48,6 +48,91 @@ class RiskController extends BaseController
$this->jsonPaginated($risks, $total, $pagination['page'], $pagination['per_page']); $this->jsonPaginated($risks, $total, $pagination['page'], $pagination['per_page']);
} }
/**
* GET /api/risks/derived
*
* Ticket #500 (fase 2, punti 1-2-3): elenco dei rischi DERIVATO dai requisiti
* del framework LEX&ISO (Determinazione ACN n. 164179 del 14 aprile 2025).
* Ogni requisito del catalogo diventa un rischio con codifica
* RSK-<misura>Com.<N> (es. requisito "comma 1." della misura GV.OC-04 ->
* RSK-GV.OC-04Com.1). Read-only: NON scrive nulla e NON tocca la tabella `risks`.
* La valutazione per-org (stato/valutazione) proviene da org_requisito_state (mig.047).
*/
public function derivedList(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$row = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$orgId]);
$orgClass = $row['entity_type'] ?? null; // essential | important | not_applicable
$rows = Database::fetchAll(
"SELECT q.id AS requisito_id, q.n, q.misura_code, q.requisito_code,
q.requisito_descr, q.applies_important, q.applies_essential,
q.risk_code AS default_risk_code, rk.risk_descr AS default_risk_descr,
m.misura_descr, m.nist_code, m.area_politica, m.ord,
s.stato, s.valutazione_rischio, s.note, s.likelihood, s.impact,
s.residual_likelihood, s.residual_impact
FROM cfg_nis2_requisiti q
JOIN cfg_nis2_misure m ON m.misura_code = q.misura_code
LEFT JOIN cfg_nis2_rischi rk ON rk.risk_code = q.risk_code
LEFT JOIN org_requisito_state s
ON s.requisito_id = q.id AND s.organization_id = ?
ORDER BY m.ord ASC, q.n ASC, q.id ASC",
[$orgId]
);
$risks = [];
foreach ($rows as $q) {
// requisito_code es. "comma 1." -> N=1 ; fallback: slug alfanumerico del codice
$commaN = null;
if (preg_match('/(\d+)/', (string) $q['requisito_code'], $mm)) {
$commaN = (int) $mm[1];
}
$suffix = $commaN !== null
? 'Com.' . $commaN
: preg_replace('/[^A-Za-z0-9]+/', '', (string) $q['requisito_code']);
$risks[] = [
'risk_code' => 'RSK-' . $q['misura_code'] . $suffix,
'requisito_id' => (int) $q['requisito_id'],
'misura_code' => $q['misura_code'],
'misura_descr' => $q['misura_descr'],
'nist_code' => $q['nist_code'],
'area_politica' => $q['area_politica'],
'requisito_code' => $q['requisito_code'],
'comma' => $commaN,
'requisito_descr' => $q['requisito_descr'],
// RISCHIO(DEFAULT) del framework (cfg_nis2_rischi.risk_descr); fallback = requisito_descr.
'risk_descr' => ($q['default_risk_descr'] !== null && $q['default_risk_descr'] !== '')
? $q['default_risk_descr'] : $q['requisito_descr'],
'applies_important' => (int) $q['applies_important'],
'applies_essential' => (int) $q['applies_essential'],
'stato' => $q['stato'] ?? 'da_valutare',
'valutazione_rischio' => $q['valutazione_rischio'] ?? null,
'note' => $q['note'] ?? null,
// Rischio INERENTE (prima del trattamento) = likelihood/impact (mig 066)
'likelihood' => isset($q['likelihood']) && $q['likelihood'] !== null ? (int) $q['likelihood'] : null,
'impact' => isset($q['impact']) && $q['impact'] !== null ? (int) $q['impact'] : null,
'score' => (isset($q['likelihood'], $q['impact']) && $q['likelihood'] !== null && $q['impact'] !== null)
? (int) $q['likelihood'] * (int) $q['impact'] : null,
// Rischio RESIDUO (dopo il trattamento) = residual_* (mig 067)
'residual_likelihood' => isset($q['residual_likelihood']) && $q['residual_likelihood'] !== null ? (int) $q['residual_likelihood'] : null,
'residual_impact' => isset($q['residual_impact']) && $q['residual_impact'] !== null ? (int) $q['residual_impact'] : null,
'residual_score' => (isset($q['residual_likelihood'], $q['residual_impact']) && $q['residual_likelihood'] !== null && $q['residual_impact'] !== null)
? (int) $q['residual_likelihood'] * (int) $q['residual_impact'] : null,
];
}
$this->jsonSuccess([
'org_class' => $orgClass,
'risks' => $risks,
'total' => count($risks),
'readonly' => true,
'source' => 'Determinazione ACN n. 164179 del 14 aprile 2025',
]);
}
/** /**
* POST /api/risks/create * POST /api/risks/create
*/ */
@@ -538,4 +623,57 @@ class RiskController extends BaseController
{ {
return ($v === null || $v === '') ? null : (float) $v; return ($v === null || $v === '') ? null : (float) $v;
} }
/**
* GET /api/risks/requisitoActions?requisito_id=NN
* Azioni collegate al rischio-da-requisito (seme del Modulo Azioni). Piu' recenti prima.
*/
public function requisitoActionsList(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$reqId = (int) $this->getParam('requisito_id', 0);
if ($reqId <= 0) { $this->jsonSuccess(['actions' => []]); return; }
$rows = Database::fetchAll(
'SELECT id, risk_code, description, status, created_at
FROM requisito_actions
WHERE organization_id = ? AND requisito_id = ?
ORDER BY created_at DESC, id DESC',
[$orgId, $reqId]
);
$this->jsonSuccess(['actions' => $rows]);
}
/**
* POST /api/risks/requisitoActions {requisito_id*, description*, risk_code?}
* Crea un'azione collegata al rischio-da-requisito. NON tocca lo stato di conformita'
* del requisito (org_requisito_state.stato): la conformita' resta atto di attuazione+evidenza.
*/
public function requisitoActionsCreate(): void
{
$this->requireOrgRole(['super_admin', 'org_admin', 'compliance_manager']);
$orgId = $this->getCurrentOrgId();
$reqId = (int) $this->getParam('requisito_id', 0);
$descr = trim((string) $this->getParam('description', ''));
$riskCode = $this->getParam('risk_code');
if ($reqId <= 0 || $descr === '') {
$this->jsonError('requisito_id e description sono obbligatori', 422);
return;
}
$id = Database::insert('requisito_actions', [
'organization_id' => $orgId,
'requisito_id' => $reqId,
'risk_code' => $riskCode ? substr((string) $riskCode, 0, 64) : null,
'description' => $descr,
'status' => 'bozza',
'created_by' => $this->getCurrentUserId(),
]);
$this->logAudit('requisito_action_created', 'requisito_action', $id);
$row = Database::fetchOne(
'SELECT id, risk_code, description, status, created_at FROM requisito_actions WHERE id = ?',
[$id]
);
$this->jsonSuccess(['action' => $row], 'Azione creata');
}
} }
@@ -2645,9 +2645,79 @@ class ServicesController extends BaseController
} }
require_once __DIR__ . '/AssetController.php'; require_once __DIR__ . '/AssetController.php';
$result = AssetController::bulkUpsert($this->currentOrgId, $items, $source, null); $result = AssetController::bulkUpsert($this->currentOrgId, $items, $source, null);
// Flussi di rete (ID.AM-03) opzionali nello stesso payload
$flowsCount = 0;
if (!empty($body['flows']) && is_array($body['flows'])) {
$flowsCount = $this->upsertFlows($this->currentOrgId, $body['flows'], $source);
}
$result['flows_ingested'] = $flowsCount;
// Se la chiave appartiene a un connettore discovery → registra il run + last_run
$this->recordDiscoveryRun((int) ($this->apiKeyRecord['id'] ?? 0), $result, $flowsCount);
$this->jsonSuccess($result, 'Asset importati', 201); $this->jsonSuccess($result, 'Asset importati', 201);
} }
/** Upsert idempotente dei flussi di rete (dedup su organization_id+external_ref). */
private function upsertFlows(int $orgId, array $flows, string $source): int
{
$n = 0;
foreach ($flows as $f) {
if (!is_array($f)) continue;
$src = trim((string) ($f['src'] ?? ''));
$dst = trim((string) ($f['dst'] ?? ''));
if ($src === '' || $dst === '') continue;
$port = isset($f['port']) ? (int) $f['port'] : null;
$proto = isset($f['protocol']) ? substr((string) $f['protocol'], 0, 12) : null;
$dir = in_array(($f['direction'] ?? ''), ['internal', 'inbound', 'outbound'], true) ? $f['direction'] : 'internal';
$ref = isset($f['external_ref'])
? substr((string) $f['external_ref'], 0, 190)
: substr($src . '>' . $dst . ':' . $port . '/' . $proto, 0, 190);
try {
Database::query(
"INSERT INTO network_flows (organization_id, src, dst, port, protocol, direction, discovery_source, external_ref, last_seen_at)
VALUES (?,?,?,?,?,?,?,?,NOW())
ON DUPLICATE KEY UPDATE port=VALUES(port), protocol=VALUES(protocol), direction=VALUES(direction), last_seen_at=NOW()",
[$orgId, substr($src, 0, 190), substr($dst, 0, 190), $port, $proto, $dir, substr($source, 0, 40), $ref]
);
$n++;
} catch (Throwable $e) {
error_log('[flows-ingest] ' . $e->getMessage());
}
}
return $n;
}
/** Registra il run del connettore discovery legato all'api_key, se esiste. */
private function recordDiscoveryRun(int $apiKeyId, array $result, int $flowsCount): void
{
if ($apiKeyId <= 0) return;
try {
$conn = Database::fetchOne(
'SELECT id FROM discovery_connectors WHERE api_key_id = ? AND organization_id = ?',
[$apiKeyId, $this->currentOrgId]
);
if (!$conn) return;
$assets = (int) ($result['imported'] ?? 0) + (int) ($result['updated'] ?? 0);
Database::insert('discovery_runs', [
'connector_id' => $conn['id'],
'organization_id' => $this->currentOrgId,
'finished_at' => date('Y-m-d H:i:s'),
'status' => 'ok',
'discovered_assets' => $assets,
'discovered_flows' => $flowsCount,
'message' => 'ingest via api',
]);
Database::query(
'UPDATE discovery_connectors SET last_run_at=NOW(), last_status=?, last_discovered=?, last_message=? WHERE id=?',
['ok', $assets, "asset {$assets}, flussi {$flowsCount}", $conn['id']]
);
} catch (Throwable $e) {
error_log('[discovery-run] ' . $e->getMessage());
}
}
/** /**
* GET /api/services/controls-monitoring * GET /api/services/controls-monitoring
* Auth: X-API-Key con scope read:compliance * Auth: X-API-Key con scope read:compliance
@@ -19,6 +19,7 @@
require_once __DIR__ . '/BaseController.php'; require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/WebhookService.php'; require_once APP_PATH . '/services/WebhookService.php';
require_once APP_PATH . '/services/VoceaService.php';
class WhistleblowingController extends BaseController class WhistleblowingController extends BaseController
{ {
@@ -126,6 +127,17 @@ class WhistleblowingController extends BaseController
{ {
$this->requireOrgRole(['org_admin', 'compliance_manager']); $this->requireOrgRole(['org_admin', 'compliance_manager']);
// ── Delega a Vocea quando il canale esterno è attivo per l'org (dormiente di
// default: VOCEA_ENABLED=false → si salta del tutto e resta il modulo legacy).
if (VoceaService::enabled()) {
$org = $this->voceaOrg();
$svc = new VoceaService();
if ($org && $svc->isEnabledForOrg($org)) {
$this->listViaVocea($svc, $org);
return;
}
}
$conditions = ['wr.organization_id = ?']; $conditions = ['wr.organization_id = ?'];
$params = [$this->getCurrentOrgId()]; $params = [$this->getCurrentOrgId()];
@@ -383,4 +395,70 @@ class WhistleblowingController extends BaseController
'timeline' => $timeline, 'timeline' => $timeline,
]); ]);
} }
// ══════════════════════════════════════════════════════════════════════
// VOCEA (integrazione whistleblowing zero-knowledge) — scaffold dormiente
// ══════════════════════════════════════════════════════════════════════
/**
* GET /api/whistleblowing/channelStatus
* Indica alla UI se mostrare il canale Vocea (embed wb-link.js) o il modulo legacy.
* Non espone segreti (mai l'API key).
*/
public function channelStatus(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
$enabled = VoceaService::enabled();
$org = $enabled ? $this->voceaOrg() : null; // query solo se attivo (colonne vocea_* presenti)
$slug = $org['vocea_tenant_slug'] ?? null;
$status = $org['vocea_channel_status'] ?? 'none';
$this->jsonSuccess([
'integration_enabled' => $enabled,
'channel_status' => $status,
'provisioned' => $enabled && !empty($slug) && $status === 'active',
'portal_url' => !empty($slug) ? VoceaService::portalUrl($slug) : null,
'portal_status_url' => !empty($slug) ? VoceaService::portalStatusUrl($slug) : null,
]);
}
/** Carica l'org corrente con le colonne vocea_* (chiamata solo quando VOCEA_ENABLED). */
private function voceaOrg(): ?array
{
return Database::fetchOne(
'SELECT * FROM organizations WHERE id = ?',
[$this->getCurrentOrgId()]
) ?: null;
}
/**
* Lista segnalazioni dal canale Vocea (metadati + ciphertext, zero-knowledge).
* Il plaintext NON transita mai: la decifratura avviene client-side dal gestore.
* NB: mapping campi da riconfermare allo smoke a MS live (shape risposta del MS).
*/
private function listViaVocea(VoceaService $svc, array $org): void
{
$query = [];
foreach (['status', 'category', 'page', 'per_page'] as $f) {
if ($this->hasParam($f)) $query[$f] = $this->getParam($f);
}
$res = $svc->listReports($org['vocea_tenant_slug'], $query);
if (!($res['ok'] ?? false)) {
$this->jsonError(
'Canale Vocea non raggiungibile: ' . ($res['error'] ?? 'errore'),
502,
'VOCEA_UPSTREAM'
);
}
$data = $res['data'] ?? [];
$reports = $data['items'] ?? ($data['reports'] ?? (is_array($data) ? $data : []));
$this->jsonSuccess([
'reports' => $reports,
'total' => $data['total'] ?? count($reports),
'source' => 'vocea',
]);
}
} }
+156 -2
View File
@@ -56,6 +56,110 @@ class AIService
. "5. ENISA, NIST e ISO sono best practice di settore, NON fonti normative vincolanti: gli obblighi italiani derivano da Direttiva (UE) 2022/2555, D.Lgs. 138/2024 e Determinazioni ACN.\n"; . "5. ENISA, NIST e ISO sono best practice di settore, NON fonti normative vincolanti: gli obblighi italiani derivano da Direttiva (UE) 2022/2555, D.Lgs. 138/2024 e Determinazioni ACN.\n";
} }
/**
* Mappa REALE della barra laterale dell'applicazione, da iniettare nel
* system prompt dell'assistente ARIA cosi' che le indicazioni di navigazione
* usino i nomi ESATTI del menu e non vengano inventati moduli/fasi inesistenti.
*
* ⚠️ TENERE SINCRONIZZATA con la fonte di verita' del menu V3:
* public/js/topnav-v3.js (array NAV_GROUPS). Se aggiungi/rinomini una voce
* di menu li', aggiorna anche questo elenco.
* (Ticket #424, 2026-06-23: ARIA citava voci di menu inesistenti.
* Aggiornato 2026-06-27 al menu IN ALTO V3 — prima era la sidebar V2.)
*/
private function navigationMapBlock(): string
{
return "\n## MAPPA DELLA NAVIGAZIONE (menu IN ALTO dell'app — interfaccia V3)\n"
. "L'app ha una BARRA IN ALTO (non piu' una barra laterale): a sinistra logo e ricerca, a destra il pulsante ROSSO \"Registra incidente\", il menu utente (Impostazioni, Esci) e il selettore lingua IT/EN. Sotto, una barra di NAVIGAZIONE con voci a tendina (mega-menu).\n"
. "Voci dirette: \"Dashboard\" · \"Calendario\".\n"
. "Menu \"Stakeholders\": Stakeholders · Supply-Chain · Attivita' su Stakeholders.\n"
. "Menu \"Struttura interna\": Organigramma · Competenze · GAP Analysis · Matrice RACI · Formazione.\n"
. "Menu \"Rischi\": Mappa dei rischi · Inventario · Policy · Connettori Discovery.\n"
. "Menu \"Monitoraggio\": Segnalazioni ricevute · Audit interni · Audit ACN · Controlli periodici · Riesame di Direzione · Scadenziario.\n"
. "Menu \"Lex e ISO\": Normative · Modello SGSI (ISO 27001) · Misure e requisiti ACN.\n"
. "Menu \"AI\": AI Cross-Analysis · Knowledge Base.\n"
. "Menu \"Guida\": Guida all'uso · Compliance Journey · Integrazioni Esterne · Impostazioni · Architettura · Simulazione Demo.\n"
. "Incidenti: si raggiungono dal pulsante ROSSO \"Registra incidente\" in alto a destra.\n"
. "\nREGOLE DI NAVIGAZIONE (vincolanti):\n"
. "1. Quando indichi DOVE cliccare o COME raggiungere una funzione, usa ESCLUSIVAMENTE i nomi delle voci qui sopra, esattamente come scritti, indicando il menu (es. \"nel menu in alto Rischi, voce Inventario\").\n"
. "2. NON inventare nomi di menu, \"fasi\", codici (es. \"A4\", \"Fase 4.1\") o moduli che NON compaiono in questo elenco. NON parlare di \"barra laterale\"/\"sidebar\": il menu e' IN ALTO.\n"
. "3. L'inventario degli asset si trova in: menu Rischi > Inventario. La valutazione del rischio in: menu Rischi > Mappa dei rischi. Misure e requisiti in: menu Lex e ISO > Misure e requisiti ACN. Per registrare un incidente: pulsante rosso \"Registra incidente\" in alto.\n"
. "4. Se l'utente cerca una funzione che non corrisponde ad alcuna voce, dillo chiaramente e suggerisci la voce piu' vicina, senza inventarne una.\n"
. "5. NON citare MAI nomi di file (es. \"rischi.html\", \"assets.html\") nE URL interni: l'utente naviga dalle voci di menu qui sopra, non dai file.\n"
. "6. NON inventare pulsanti, campi o passaggi che non esistono. Se non sei certo che una funzione/pagina esista, NON darla per scontata: dillo e invita ad aprire la guida \"?\" della pagina; usa solo le funzioni descritte nel contesto/guida della pagina, se forniti.\n";
}
/**
* Griglia ESATTA delle combo della finestra «Valuta Rilevanza NIS2»
* (Inventario asset), generata dalla fonte di verita' AssetScoringService::GRID.
* Iniettata nel system prompt di ARIA cosi' che, quando l'utente chiede del
* significato di una combo/criterio o dei punteggi, ARIA usi i valori REALI
* della finestra invece di inventarne (ticket #429, 2026-06-24: ARIA dava una
* risposta sulle combo "non corrispondente alla finestra").
*/
private function relevanceGridBlock(): string
{
require_once __DIR__ . '/AssetScoringService.php';
if (!class_exists('AssetScoringService')) return '';
$lines = [];
$lines[] = "\n## RILEVANZA NIS2 — combo della finestra «Valuta Rilevanza NIS2» (Inventario asset)";
$lines[] = "Questa e' la griglia ESATTA della finestra di valutazione: 6 criteri pesati, punteggio totale 0-100, asset RILEVANTE NIS2 se il totale e' >= "
. AssetScoringService::RELEVANCE_THRESHOLD . " (requisito GV.OC-04). Quando l'utente chiede il significato di una combo, di un'opzione o dei punteggi, usa SOLO queste voci e questi valori, NON inventarne altri.";
foreach (AssetScoringService::GRID as $crit) {
$lines[] = sprintf("\n%s (max %d punti) — %s", $crit['label'], $crit['max'], $crit['help']);
foreach ($crit['options'] as $opt) {
$lines[] = sprintf(" - %s (%d pt): %s", $opt['label'], $opt['points'], $opt['desc']);
}
}
return implode("\n", $lines) . "\n";
}
/**
* Snapshot LIVE dei dati dell'organizzazione dell'utente, da iniettare nel
* system prompt di ARIA così può rispondere su numeri SPECIFICI (rischi,
* incidenti, asset, score...) e non solo su normativa/guida.
*
* SICUREZZA: chiamato SOLO dopo verifica membership (AiController) → scope
* rigoroso `organization_id = $orgId`. PRIVACY: niente nome/fatturato dell'org
* (coerente con l'anonimizzazione già in uso); solo aggregati + titoli operativi.
* Ogni query è in try/catch: una colonna assente non rompe ARIA (degrada).
*/
private function orgDataSnapshotBlock(int $orgId): string
{
if ($orgId <= 0) return '';
$q = function ($sql, $p = []) { try { return Database::fetchOne($sql, $p); } catch (\Throwable $e) { return null; } };
$all = function ($sql, $p = []) { try { return Database::fetchAll($sql, $p); } catch (\Throwable $e) { return []; } };
$org = $q("SELECT sector, entity_type, voluntary_compliance FROM organizations WHERE id=?", [$orgId]);
$asmt = $q("SELECT overall_score FROM assessments WHERE organization_id=? AND status='completed' ORDER BY completed_at DESC LIMIT 1", [$orgId]);
$risk = $q("SELECT COUNT(*) tot, SUM(inherent_risk_score>=20) ch, SUM(inherent_risk_score BETWEEN 10 AND 19) med, SUM(inherent_risk_score<10) low FROM risks WHERE organization_id=? AND status!='closed'", [$orgId]);
$top = $all("SELECT title, inherent_risk_score s FROM risks WHERE organization_id=? AND status!='closed' ORDER BY inherent_risk_score DESC LIMIT 5", [$orgId]);
$inc = $q("SELECT COUNT(*) c FROM incidents WHERE organization_id=? AND status NOT IN ('closed','post_mortem')", [$orgId]);
$asTot = $q("SELECT COUNT(*) c FROM assets WHERE organization_id=? AND status='active'", [$orgId]);
$asRel = $q("SELECT COUNT(*) c FROM assets WHERE organization_id=? AND is_nis2_relevant=1", [$orgId]);
$sup = $q("SELECT COUNT(*) c FROM suppliers WHERE organization_id=? AND criticality IN ('high','critical') AND security_requirements_met=0", [$orgId]);
$nc = $q("SELECT COUNT(*) c FROM non_conformities WHERE organization_id=? AND status NOT IN ('closed','resolved','verified')", [$orgId]);
$tr = $q("SELECT COUNT(*) tot, SUM(status='completed') done, SUM(status='overdue') ovd FROM training_assignments WHERE organization_id=?", [$orgId]);
$pol = $all("SELECT status, COUNT(*) n FROM policies WHERE organization_id=? GROUP BY status", [$orgId]);
$L = [];
$L[] = "\n## DATI DELL'ORGANIZZAZIONE DELL'UTENTE (snapshot live — SOLO la sua org)";
$L[] = "Usa questi numeri per risposte specifiche sulla sua situazione. Sono dati riservati della sua organizzazione: non confrontarli con altre org. Se un dato è 0/assente, dillo con chiarezza.";
if ($org) $L[] = "- Classificazione: " . ($org['entity_type'] ?: 'n/d') . (!empty($org['voluntary_compliance']) ? ' (adesione volontaria)' : '') . ($org['sector'] ? ", settore " . $org['sector'] : '');
$L[] = "- Compliance score (ultimo assessment): " . ($asmt && $asmt['overall_score'] !== null ? round((float)$asmt['overall_score']) . '%' : 'nessun assessment completato');
if ($risk) $L[] = "- Rischi aperti: " . (int)$risk['tot'] . " (critico/alto " . (int)$risk['ch'] . ", medio " . (int)$risk['med'] . ", basso " . (int)$risk['low'] . ")";
if ($top) $L[] = " Top rischi: " . implode('; ', array_map(fn($x) => $x['title'] . ' (' . (int)$x['s'] . ')', $top));
$L[] = "- Incidenti aperti: " . (int)($inc['c'] ?? 0);
$L[] = "- Asset attivi: " . (int)($asTot['c'] ?? 0) . " (rilevanti NIS2: " . (int)($asRel['c'] ?? 0) . ")";
$L[] = "- Fornitori critici con requisiti sicurezza NON soddisfatti: " . (int)($sup['c'] ?? 0);
$L[] = "- Non conformità aperte: " . (int)($nc['c'] ?? 0);
if ($tr && (int)$tr['tot'] > 0) $L[] = "- Formazione: " . (int)$tr['done'] . "/" . (int)$tr['tot'] . " completate, " . (int)$tr['ovd'] . " scadute";
if ($pol) $L[] = "- Policy: " . implode(', ', array_map(fn($x) => $x['status'] . ' ' . $x['n'], $pol));
return implode("\n", $L) . "\n";
}
/** /**
* Analizza risultati gap analysis e genera raccomandazioni * Analizza risultati gap analysis e genera raccomandazioni
*/ */
@@ -702,9 +806,59 @@ PROMPT;
error_log('[AIService::askWithRag] RAG failed, fallback diretto: ' . $e->getMessage()); error_log('[AIService::askWithRag] RAG failed, fallback diretto: ' . $e->getMessage());
} }
$systemPrompt = "Sei un esperto consulente di cybersecurity NIS2 (EU 2022/2555) e D.Lgs. 138/2024.\n" $systemPrompt = "Sei ARIA, l'assistente dell'applicazione NIS2 Agile, esperta di cybersecurity NIS2 (EU 2022/2555) e D.Lgs. 138/2024.\n"
. "Rispondi in modo preciso e cita le fonti del contesto quando rilevanti.\n" . "Rispondi in modo preciso e cita le fonti del contesto quando rilevanti.\n"
. $this->authoritativeSourcesBlock(); . "## REGOLA ANTI-INVENZIONE (VINCOLANTE, priorità massima)\n"
. "NON inventare MAI record specifici — segnalazioni, reclami, ticket, incidenti, rischi, "
. "nomi di persone, date, citazioni, contenuti o esiti di gestione — che NON siano "
. "ESPLICITAMENTE presenti nei «DATI DELL'ORGANIZZAZIONE» o nel «Contesto documentale» qui forniti. "
. "In particolare: i RECLAMI / le SEGNALAZIONI / i TICKET di supporto dei singoli utenti (es. "
. "\"il reclamo di Mario Rossi\") sono gestiti in un sistema separato e NON ti sono accessibili. "
. "Se ti viene chiesto di analizzare un reclamo/segnalazione/ticket/incidente specifico e quel dato "
. "NON compare nei dati forniti, RISPONDI che non hai accesso a quel record e indica dove "
. "consultarlo nell'app (es. sezione Gestione > Segnalazioni o il pulsante di supporto), "
. "SENZA inventare alcun dettaglio. Meglio dire \"non ho quel dato\" che fabbricare una risposta.\n"
. $this->authoritativeSourcesBlock()
. $this->navigationMapBlock();
// Pagina corrente dell'utente (se inviata dal frontend): aiuta ARIA a
// dare indicazioni contestuali invece di rimandare a moduli a caso.
$page = trim((string) ($userContext['page'] ?? ''));
$pageId = trim((string) ($userContext['page_id'] ?? ''));
if ($page !== '' || $pageId !== '') {
$systemPrompt .= "\n## CONTESTO UTENTE\n"
. "L'utente si trova attualmente nella pagina: \"" . ($page !== '' ? $page : $pageId) . "\""
. ($pageId !== '' ? " (id: {$pageId})" : '') . ". "
. "Tieni conto di dove si trova quando dai indicazioni di navigazione.\n";
}
// ALLINEAMENTO ARIA ↔ HELP: inietta il testo dell'help contestuale "?" della
// pagina corrente (stessa fonte help.js che l'utente vede) così le risposte
// sono coerenti con la guida e non divergono. Fonte autorevole per la pagina.
$pageHelp = trim((string) ($userContext['page_help'] ?? ''));
if ($pageHelp !== '') {
$systemPrompt .= "\n## GUIDA CONTESTUALE DELLA PAGINA (stesso testo dell'help «?» che l'utente vede ora)\n"
. "Allinea le tue risposte a questa guida: è la fonte ufficiale per la pagina corrente; non contraddirla.\n"
. $pageHelp . "\n";
}
// Se l'utente e' nell'Inventario o la domanda riguarda la valutazione di
// rilevanza NIS2, inietta la griglia ESATTA delle combo (fonte di verita'
// AssetScoringService::GRID) per evitare risposte non corrispondenti alla
// finestra reale (ticket #429).
$onInventory = $pageId === 'assets' || stripos($page, 'asset') !== false || stripos($page, 'inventario') !== false;
if ($onInventory || preg_match('/rilevanz|combo|criticit|punteggio|scoring|valutazione|gv\.?oc/iu', $question)) {
$systemPrompt .= $this->relevanceGridBlock();
}
// DATI ORG dell'utente: iniettati SOLO se la membership è stata verificata
// (AiController imposta org_data_ok dopo il check user_organizations / super_admin)
// → scope rigoroso, niente leak cross-org da X-Organization-Id falsificato.
$snapOrgId = (int) ($userContext['organization_id'] ?? 0);
if ($snapOrgId > 0 && !empty($userContext['org_data_ok'])) {
$systemPrompt .= $this->orgDataSnapshotBlock($snapOrgId);
}
if (!empty($contextBlock)) { if (!empty($contextBlock)) {
$systemPrompt .= "\n## Contesto documentale (knowledge base)\n" . $contextBlock $systemPrompt .= "\n## Contesto documentale (knowledge base)\n" . $contextBlock
. "\n\nQuando rispondi, cita esplicitamente i numeri tra parentesi quadre [1], [2], ... che corrispondono ai documenti del contesto."; . "\n\nQuando rispondi, cita esplicitamente i numeri tra parentesi quadre [1], [2], ... che corrispondono ai documenti del contesto.";
+34 -34
View File
@@ -33,12 +33,12 @@ class AssetScoringService
'max' => 25, 'max' => 25,
'help' => 'Quanto il sistema e essenziale per l\'erogazione dei servizi core business.', 'help' => 'Quanto il sistema e essenziale per l\'erogazione dei servizi core business.',
'options' => [ 'options' => [
'critical' => ['label' => 'Critico', 'points' => 25], 'critical' => ['label' => 'Critico', 'points' => 25, 'desc' => 'Sistema indispensabile: un suo fermo blocca subito i servizi core dell\'organizzazione.'],
'very_high' => ['label' => 'Molto Alto', 'points' => 20], 'very_high' => ['label' => 'Molto Alto', 'points' => 20, 'desc' => 'Sistema primario: un fermo degrada gravemente i servizi core, con alternative molto limitate.'],
'high' => ['label' => 'Alto', 'points' => 15], 'high' => ['label' => 'Alto', 'points' => 15, 'desc' => 'Sistema importante: il fermo rallenta i servizi core ma esistono alternative parziali.'],
'medium' => ['label' => 'Medio', 'points' => 10], 'medium' => ['label' => 'Medio', 'points' => 10, 'desc' => 'Sistema di supporto: il fermo ha impatto contenuto e gestibile sui servizi core.'],
'low' => ['label' => 'Basso', 'points' => 5], 'low' => ['label' => 'Basso', 'points' => 5, 'desc' => 'Sistema accessorio: il fermo incide poco sull\'operativita.'],
'negligible' => ['label' => 'Trascurabile', 'points' => 0], 'negligible' => ['label' => 'Trascurabile', 'points' => 0, 'desc' => 'Sistema non essenziale: nessun impatto pratico sui servizi core.'],
], ],
], ],
'c2_disruption_impact' => [ 'c2_disruption_impact' => [
@@ -46,12 +46,12 @@ class AssetScoringService
'max' => 25, 'max' => 25,
'help' => 'Conseguenze di un\'interruzione in termini di durata e utenti impattati.', 'help' => 'Conseguenze di un\'interruzione in termini di durata e utenti impattati.',
'options' => [ 'options' => [
'gt24h_gt70' => ['label' => '>24h + >70% utenti', 'points' => 25], 'gt24h_gt70' => ['label' => '>24h + >70% utenti', 'points' => 25, 'desc' => 'Interruzione oltre 24 ore che coinvolge piu del 70% degli utenti.'],
'h8_24_50_70' => ['label' => '8-24h + 50-70% utenti', 'points' => 20], 'h8_24_50_70' => ['label' => '8-24h + 50-70% utenti', 'points' => 20, 'desc' => 'Interruzione da 8 a 24 ore, dal 50 al 70% degli utenti coinvolti.'],
'h4_8_30_50' => ['label' => '4-8h + 30-50% utenti', 'points' => 15], 'h4_8_30_50' => ['label' => '4-8h + 30-50% utenti', 'points' => 15, 'desc' => 'Interruzione da 4 a 8 ore, dal 30 al 50% degli utenti coinvolti.'],
'h1_4_10_30' => ['label' => '1-4h + 10-30% utenti', 'points' => 10], 'h1_4_10_30' => ['label' => '1-4h + 10-30% utenti', 'points' => 10, 'desc' => 'Interruzione da 1 a 4 ore, dal 10 al 30% degli utenti coinvolti.'],
'lt1h_lt10' => ['label' => '<1h + <10% utenti', 'points' => 5], 'lt1h_lt10' => ['label' => '<1h + <10% utenti', 'points' => 5, 'desc' => 'Interruzione sotto 1 ora, meno del 10% degli utenti coinvolti.'],
'none' => ['label' => 'Nessun impatto', 'points' => 0], 'none' => ['label' => 'Nessun impatto', 'points' => 0, 'desc' => 'Nessun impatto operativo apprezzabile in caso di interruzione.'],
], ],
], ],
'c3_data_processed' => [ 'c3_data_processed' => [
@@ -59,11 +59,11 @@ class AssetScoringService
'max' => 20, 'max' => 20,
'help' => 'Sensibilita e criticita dei dati gestiti dal sistema.', 'help' => 'Sensibilita e criticita dei dati gestiti dal sistema.',
'options' => [ 'options' => [
'gdpr_art9' => ['label' => 'Dati Sensibili Art.9 GDPR', 'points' => 20], 'gdpr_art9' => ['label' => 'Dati Sensibili Art.9 GDPR', 'points' => 20, 'desc' => 'Categorie particolari di dati personali (salute, biometrici, ecc.) ai sensi dell\'Art.9 GDPR.'],
'personal_large' => ['label' => 'Dati Personali larga scala', 'points' => 15], 'personal_large' => ['label' => 'Dati Personali larga scala', 'points' => 15, 'desc' => 'Dati personali comuni trattati su larga scala.'],
'personal_fin' => ['label' => 'Dati Personali + Finanziari', 'points' => 10], 'personal_fin' => ['label' => 'Dati Personali + Finanziari', 'points' => 10, 'desc' => 'Dati personali insieme a dati finanziari o di pagamento.'],
'confidential' => ['label' => 'Dati Aziendali Riservati', 'points' => 5], 'confidential' => ['label' => 'Dati Aziendali Riservati', 'points' => 5, 'desc' => 'Dati aziendali riservati non personali (know-how, contratti).'],
'public' => ['label' => 'Dati Pubblici', 'points' => 0], 'public' => ['label' => 'Dati Pubblici', 'points' => 0, 'desc' => 'Solo dati pubblici o gia liberamente divulgabili.'],
], ],
], ],
'c4_dependencies' => [ 'c4_dependencies' => [
@@ -71,12 +71,12 @@ class AssetScoringService
'max' => 15, 'max' => 15,
'help' => 'Quanti altri sistemi critici dipendono da questo sistema.', 'help' => 'Quanti altri sistemi critici dipendono da questo sistema.',
'options' => [ 'options' => [
'ge5_critical' => ['label' => '>=5 sistemi critici', 'points' => 15], 'ge5_critical' => ['label' => '>=5 sistemi critici', 'points' => 15, 'desc' => 'Da questo sistema dipendono 5 o piu sistemi critici.'],
'n3_4_critical' => ['label' => '3-4 sistemi critici', 'points' => 12], 'n3_4_critical' => ['label' => '3-4 sistemi critici', 'points' => 12, 'desc' => 'Da questo sistema dipendono 3 o 4 sistemi critici.'],
'n2_critical' => ['label' => '2 sistemi critici', 'points' => 9], 'n2_critical' => ['label' => '2 sistemi critici', 'points' => 9, 'desc' => 'Da questo sistema dipendono 2 sistemi critici.'],
'n1_critical' => ['label' => '1 sistema critico', 'points' => 6], 'n1_critical' => ['label' => '1 sistema critico', 'points' => 6, 'desc' => 'Da questo sistema dipende 1 sistema critico.'],
'noncritical' => ['label' => '1-2 sistemi non critici', 'points' => 3], 'noncritical' => ['label' => '1-2 sistemi non critici', 'points' => 3, 'desc' => 'Dipendono solo 1-2 sistemi non critici.'],
'none' => ['label' => 'Nessuna dipendenza', 'points' => 0], 'none' => ['label' => 'Nessuna dipendenza', 'points' => 0, 'desc' => 'Nessun altro sistema dipende da questo.'],
], ],
], ],
'c5_exposure' => [ 'c5_exposure' => [
@@ -84,12 +84,12 @@ class AssetScoringService
'max' => 10, 'max' => 10,
'help' => 'Superficie di attacco ed esposizione del sistema.', 'help' => 'Superficie di attacco ed esposizione del sistema.',
'options' => [ 'options' => [
'internet_no_mfa' => ['label' => 'Internet pubblico senza MFA', 'points' => 10], 'internet_no_mfa' => ['label' => 'Internet pubblico senza MFA', 'points' => 10, 'desc' => 'Esposto su Internet pubblico senza autenticazione a piu fattori.'],
'internet_mfa' => ['label' => 'Internet con MFA', 'points' => 8], 'internet_mfa' => ['label' => 'Internet con MFA', 'points' => 8, 'desc' => 'Esposto su Internet ma protetto da autenticazione a piu fattori.'],
'partner_net' => ['label' => 'Reti partner/fornitori', 'points' => 6], 'partner_net' => ['label' => 'Reti partner/fornitori', 'points' => 6, 'desc' => 'Raggiungibile dalle reti di partner o fornitori.'],
'intranet' => ['label' => 'Rete aziendale intranet', 'points' => 4], 'intranet' => ['label' => 'Rete aziendale intranet', 'points' => 4, 'desc' => 'Raggiungibile solo dalla rete aziendale interna.'],
'mgmt_isolated' => ['label' => 'Rete gestione isolata', 'points' => 2], 'mgmt_isolated' => ['label' => 'Rete gestione isolata', 'points' => 2, 'desc' => 'Accessibile solo da una rete di gestione isolata.'],
'air_gapped' => ['label' => 'Completamente isolato', 'points' => 0], 'air_gapped' => ['label' => 'Completamente isolato', 'points' => 0, 'desc' => 'Completamente isolato, senza connettivita di rete.'],
], ],
], ],
'c6_regulatory' => [ 'c6_regulatory' => [
@@ -97,11 +97,11 @@ class AssetScoringService
'max' => 5, 'max' => 5,
'help' => 'Se il sistema e soggetto a obblighi normativi o contrattuali specifici.', 'help' => 'Se il sistema e soggetto a obblighi normativi o contrattuali specifici.',
'options' => [ 'options' => [
'nis2_required' => ['label' => 'Richiesto da NIS2', 'points' => 5], 'nis2_required' => ['label' => 'Richiesto da NIS2', 'points' => 5, 'desc' => 'Sistema che rientra negli obblighi NIS2 dell\'organizzazione.'],
'mandatory_cert' => ['label' => 'Certificazioni obbligatorie', 'points' => 4], 'mandatory_cert' => ['label' => 'Certificazioni obbligatorie', 'points' => 4, 'desc' => 'Soggetto a certificazioni obbligatorie (es. ISO o settoriali).'],
'strict_sla' => ['label' => 'Obblighi SLA stringenti', 'points' => 3], 'strict_sla' => ['label' => 'Obblighi SLA stringenti', 'points' => 3, 'desc' => 'Vincolato a SLA contrattuali stringenti.'],
'external_audit' => ['label' => 'Audit esterni regolari', 'points' => 2], 'external_audit' => ['label' => 'Audit esterni regolari', 'points' => 2, 'desc' => 'Sottoposto regolarmente ad audit esterni.'],
'none' => ['label' => 'Nessun obbligo', 'points' => 0], 'none' => ['label' => 'Nessun obbligo', 'points' => 0, 'desc' => 'Nessun obbligo normativo o contrattuale specifico.'],
], ],
], ],
]; ];
+169
View File
@@ -0,0 +1,169 @@
<?php
/**
* NIS2 Agile - VoceaService
*
* Client per l'integrazione con Vocea (nexus-whistleblowing-ms), il prodotto
* whistleblowing ZERO-KNOWLEDGE della suite. Lettura/gestione server-to-server via
* API key per-tenant (header X-API-Key) su api.vocea.cloud.
*
* SCAFFOLD DORMIENTE: finché VOCEA_ENABLED=false (default) ogni metodo che richiede
* rete ritorna ['ok'=>false,'error'=>'DISABLED']. Si "accende" così:
* 1. applicare migrate_063_vocea_channel.php (colonne organizations.vocea_*)
* 2. VOCEA_ENABLED=true + VOCEA_API_KEY (vault tier1__nis2-app__vocea__*)
* 3. impostare organizations.vocea_tenant_slug + vocea_channel_status='active' sul canale
*
* CONFINE ZERO-KNOWLEDGE: l'API NON ritorna MAI contenuti in chiaro. listReports
* restituisce metadati + ciphertext; la decifratura avviene client-side dal gestore.
*
* Contratto: whistleblowing-agile/docs/API_GATEWAY_INTEGRATIONS.md
* GET /integrations/reports (scope reports:read) — lista metadati (paginata)
* GET /integrations/reports/{code}/status (scope status:read)
* POST /integrations/reports/submit (scope reports:submit) — payload GIÀ cifrato
* GET /integrations/keyholders · POST /integrations/keyholders (enroll public key RSA)
* GET /tenants/{slug}/info (pubblico — public key del canale)
*
* NB: base URL/prefisso (`/api/wb/v1` vs `/v1`) e shape esatto risposte vanno
* riconfermati allo smoke a MS live; sono parametrizzati (VOCEA_BASE_URL) apposta.
*/
class VoceaService
{
private const TIMEOUT_SEC = 12;
private const CONNECT_TIMEOUT_SEC = 6;
private string $baseUrl;
private string $apiBase; // base NON versionata (endpoint pubblici tipo /tenants/{slug}/info)
private string $apiKey;
public function __construct(?string $apiKey = null)
{
$this->baseUrl = rtrim(defined('VOCEA_BASE_URL') ? VOCEA_BASE_URL : 'https://api.vocea.cloud/api/wb/v1', '/');
// gli endpoint /integrations/* sono versionati (/v1); gli endpoint pubblici (/tenants/{slug}/info) NO.
$this->apiBase = preg_replace('#/v\d+$#', '', $this->baseUrl);
$this->apiKey = $apiKey ?? (defined('VOCEA_API_KEY') ? VOCEA_API_KEY : '');
}
/** Flag globale: l'integrazione Vocea è attiva? */
public static function enabled(): bool
{
return defined('VOCEA_ENABLED') && VOCEA_ENABLED === true;
}
/** Il canale è utilizzabile per questa organization? (flag globale + slug + stato active + key). */
public function isEnabledForOrg(array $org): bool
{
return self::enabled()
&& !empty($org['vocea_tenant_slug'])
&& (($org['vocea_channel_status'] ?? 'none') === 'active')
&& $this->apiKey !== '';
}
/**
* URL del portale segnalante (per embed via wb-link.js).
* NB: iframe diretto è bloccato dalla CSP `frame-ancestors 'self'` di Vocea →
* usare il widget wb-link.js oppure far allargare la CSP al dominio NIS2.
* Path reale (confermato smoke 23/6): /{locale}/segnala/{slug}.
*/
public static function portalUrl(string $slug, string $locale = 'it'): string
{
$base = rtrim(defined('VOCEA_PORTAL_URL') ? VOCEA_PORTAL_URL : 'https://app.vocea.cloud', '/');
return "{$base}/" . rawurlencode($locale) . '/segnala/' . rawurlencode($slug);
}
/** URL portale per il tracciamento stato di una segnalazione (/{locale}/stato/{slug}). */
public static function portalStatusUrl(string $slug, string $locale = 'it'): string
{
$base = rtrim(defined('VOCEA_PORTAL_URL') ? VOCEA_PORTAL_URL : 'https://app.vocea.cloud', '/');
return "{$base}/" . rawurlencode($locale) . '/stato/' . rawurlencode($slug);
}
// ── Read / management (server-to-server, API key) ────────────────────────
/** Public key del canale (endpoint pubblico NON versionato, no API key). */
public function tenantInfo(string $slug): array
{
// /tenants/{slug}/info vive sotto /api/wb (NON /api/wb/v1) — confermato smoke 23/6.
return $this->request('GET', '/tenants/' . rawurlencode($slug) . '/info', null, false, $this->apiBase);
}
/** Lista metadati segnalazioni del canale (paginata, SENZA plaintext). */
public function listReports(string $slug, array $query = []): array
{
$qs = $query ? ('?' . http_build_query($query)) : '';
return $this->request('GET', "/integrations/reports{$qs}");
}
/** Stato di una segnalazione tramite codice ricevuta. */
public function getReportStatus(string $slug, string $code): array
{
return $this->request('GET', '/integrations/reports/' . rawurlencode($code) . '/status');
}
/** Invio segnalazione: $encryptedPayload è GIÀ cifrato lato client (zero-knowledge). */
public function submitReport(string $slug, array $encryptedPayload): array
{
return $this->request('POST', '/integrations/reports/submit', $encryptedPayload);
}
public function listKeyHolders(string $slug): array
{
return $this->request('GET', '/integrations/keyholders');
}
public function enrollKeyHolder(string $slug, array $keyHolder): array
{
return $this->request('POST', '/integrations/keyholders', $keyHolder);
}
// ── HTTP ─────────────────────────────────────────────────────────────────
/**
* @param bool $auth Se true invia X-API-Key (default). Endpoint pubblici (tenantInfo) → false.
* @param ?string $baseOverride Base URL alternativa (es. NON versionata per /tenants/{slug}/info).
* @return array ['ok'=>bool,'status'=>int,'data'=>mixed,'error'=>?string]
*/
private function request(string $method, string $path, ?array $body = null, bool $auth = true, ?string $baseOverride = null): array
{
if (!self::enabled()) {
return ['ok' => false, 'error' => 'DISABLED', 'message' => 'Integrazione Vocea non attiva (VOCEA_ENABLED=false).'];
}
if ($auth && $this->apiKey === '') {
return ['ok' => false, 'error' => 'NOT_PROVISIONED', 'message' => 'VOCEA_API_KEY non configurata (vault tier1__nis2-app__vocea__*).'];
}
$headers = ['Accept: application/json'];
if ($auth) { $headers[] = 'X-API-Key: ' . $this->apiKey; }
$ch = curl_init(($baseOverride ?? $this->baseUrl) . $path);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_TIMEOUT => self::TIMEOUT_SEC,
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT_SEC,
];
if ($body !== null) {
$opts[CURLOPT_POSTFIELDS] = json_encode($body, JSON_UNESCAPED_UNICODE);
$headers[] = 'Content-Type: application/json';
}
$opts[CURLOPT_HTTPHEADER] = $headers;
curl_setopt_array($ch, $opts);
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($raw === false || $status === 0) {
return ['ok' => false, 'status' => 0, 'error' => 'NETWORK', 'message' => $err ?: 'connessione fallita'];
}
$decoded = json_decode($raw, true);
$ok = $status >= 200 && $status < 300;
return [
'ok' => $ok,
'status' => $status,
'data' => $decoded,
'error' => $ok ? null : (($decoded['error']['code'] ?? null) ?: ('HTTP_' . $status)),
];
}
}
+127 -1
View File
@@ -2,6 +2,123 @@
> Il 2026-05-29 ci sono state DUE sessioni: **pomeriggio** e **mattina** (TRPG). Il 2026-05-30 sessione lunga: gap competitivi P1/P2/P3 + connettori + review multi-agente + fix. > Il 2026-05-29 ci sono state DUE sessioni: **pomeriggio** e **mattina** (TRPG). Il 2026-05-30 sessione lunga: gap competitivi P1/P2/P3 + connettori + review multi-agente + fix.
## 2026-08-25 — 2 segnalazioni Simon: Documenti Istituzionali (bug NO_ORG) + Organigramma freeze — RISOLTE
Memorie: [[project_institutional_docs]], [[reference_createicons_mutationobserver_loop]], [[project_prod_topology_host_fpm]].
**Segn. 1 — Documenti Istituzionali (#499 ri-segnalato, screenshot: solo "Aggiungi voce", niente 5 voci):** era un **BUG vero, non cache** (mia diagnosi iniziale "cache" SBAGLIATA, corretta con Simon). Causa: `InstitutionalDocsController::list()` usava `requireAuth()+getCurrentOrgId()` ma `currentOrgId` e' popolato SOLO da `requireOrgAccess()` → endpoint sempre **NO_ORG (400)** → `loadDocs` riceveva `standards=[]` → card con sola "Aggiungi voce". **Non ha MAI funzionato a runtime.** Fix (commit **396783c**): backend `list()` usa `resolveOrgId()` (risolve X-Organization-Id senza lanciare) + restituisce SEMPRE le 5 voci; frontend `dashboard.js` `renderDocsCard()`+`STD_FALLBACK` (5 voci sempre visibili anche se API fallisce). Buster dashboard.js `20260825`, php-fpm ricaricato. Verificato puppeteer (996003): 5 voci visibili, api standards=5, 0 errori. **GOTCHA: endpoint per-org non-membership → usare `resolveOrgId()`, NON `requireAuth()+getCurrentOrgId()`.**
**Segn. 2 — Organigramma "Nuovo Ruolo" bloccava il sito:** freeze ELIMINATO. Causa NON era ciclo dati (0 cicli in org_roles, verificato) ma l'anti-pattern **MutationObserver(subtree)→`lucide.createIcons()`** che si auto-ritriggerava (createIcons muta il DOM). Fix (commit **1f9d837**): debounce+disconnect. + hardening guardia anti-ciclo client+server `buildTree`/`orgNodeHtml` (commit **bfda75b**, php-fpm reload). **Bonus proattivo:** stesso anti-pattern trovato e corretto in **raci.html + competenze.html** (commit **69f3894**). Test puppeteer: "Nuovo Ruolo" apre il modale (visibile), 0 errori — il "nessun effetto" di Simon era cache/versione intermedia → hard-reload. Help online: aggiunta sezione «Documenti istituzionali» (commit **9eeef78**).
Email a Simon + copia cristiano.benassati@ (201) per entrambe (con scuse per la diagnosi "cache" errata sul #499). ⚠️ Chiave SSH temporanea rigenerata da Cristiano (`.ssh-temp/hetzner_key`, finestra 12h) — scade ~22:00 UTC. Nessuna nuova migrazione (prossima=068).
## 2026-08-03 — Rework pagina Rischi (richieste Simon): matrice 4 fasce + toggle inerente/residuo, trasversali a 12 colonne — LIVE v1.27.0
Memorie: [[project_ticket_500_risks_page]] (aggiornata), [[project_modulo_azioni]].
Nuove richieste Simon (via email, non ticket — #501 già RESOLVED) sulla finestra Rischi, tutte IMPLEMENTATE e LIVE (commit **4809d8e** rework + **f421f4e** help/manuale/KB):
1. **Matrice 5×5 a 4 fasce** (eliminata 10-14): 1-2 basso verde, 3-8 medio arancione, 9-12 alto rosso tenue, 15-25 critico rosso — stesse soglie dell'Alert. `matrixBand()` + CSS `.matrix-cell.band-low/med/high/crit` (rimosse `.level-1..10`).
2. **Toggle Inerente/Residuo** sopra la matrice (`setMatrixView('derived'|'trans', 'inherent'|'residual')` + `matrixPoints()` legge likelihood/impact o residual_*).
3. Rischi trasversali: **3.1** sezione in FONDO (matrice requisiti `#view-matrix`/grid `risk-matrix-grid-derived` risalita dopo la tabella derivata); **3.2** tabella `#risks-table-body` rifatta a **12 colonne** (`renderRisksTable` + `scoreSelT`/`onTransversalScoreChange`→`api.updateRisk`, update parziale P/I+residuo); **3.3** tasti `btn-ai-suggest`/`btn-new-risk` spostati in testa (tolti dall'header pagina); **3.4** **2ª matrice** `#view-matrix-trans`/grid `risk-matrix-grid-transversal` con toggle; **3.5** tasto Elimina (`confirmDeleteRisk`) per riga. `viewRiskDetail`/`backToList` aggiornati (nascondono/mostrano anche allhazard-section + view-matrix-trans).
Verificato **puppeteer** 996002: 4 bande, 2 toggle funzionanti, 14 th (12 col), 6 delete, 24 select inline, sezione in fondo, 0 errori. **version 1.27.0** + SW cache bump. help.js (§matrice+trasversali) + manuale (§6 matrici, §7 trasversali) + KB `RISCHI_MODELLO.md` re-ingest (3 chunk). Buster help.js `20260801`. Email Simon + copia cristiano.benassati@ (201). **Nota aperta**: Alert dei trasversali apre `viewRiskDetail` (scheda rischio/trattamenti), NON il popup crea-azione (che è sui rischi dai requisiti via `requisito_actions`) — proposto a Simon di unificarlo se lo vuole. Ancora solo frontend; DB invariato (prossima mig **068**).
## 2026-08-01 — #501 punto 3 RISOLTO (all-hazard comprimibile) + ticket RESOLVED
Memorie: [[project_ticket_500_risks_page]], [[project_modulo_azioni]].
Compromesso su #501 p3 APPROVATO da Cristiano/Simon e IMPLEMENTATO (commit **ed0b16e**). La sezione "Rischi aggiuntivi (all-hazard)" NON cancellata ma trasformata in `<details>` **comprimibile** rinominato **«Rischi trasversali (approccio multirischio / all-hazards) — art. 24 D.Lgs. 138/2024»**: header sempre visibile + contatore `#allhazard-count`, **apri-se-vuota** (`updateAllhazardHeader`: count 0→aperta per audit, >0→chiusa=meno ingombro), riservata admin, matrice fuori dal details. i18n custom_heading/custom_help IT+EN + help.js + manuale + KB (re-ingest 3 chunk) allineati; buster help/i18n `20260731`. Validato nis2-expert PRIMA. Collaudo puppeteer 996002: contatore 6, chiusa di default, matrice separata, 12 col ok, deploy statico verificato curl (0 residui "aggiuntivi"). Email+nota #501 (201). **Ticket #501 → RESOLVED** (PATCH `/tickets/501/status` {status:RESOLVED} + header X-Tenant-Id:7 → 200, verificato). Tutti e 3 i punti chiusi (scorrimento + filtro classe + all-hazard riorganizzata). Mail di fine lavoro a Simon e copia a cristiano.benassati@gmail.com. **Gotcha:** endpoint cambio stato ticket = `PATCH /tickets/{id}/status`.
## 2026-07-31 — Rischi 12 col: 2 correzioni Simon (LIVE) + analisi ticket aperti + proposta #501 p3 (no codice)
Memorie: [[project_modulo_azioni]], [[project_ticket_500_risks_page]].
**A. Riscontro Simon sulla 12-col (applicato e LIVE, commit 8799620):** (1) titoli di GRUPPO sui tripletti Prob/Impatto/Valutazione → "Rischio Inerente"/"Rischio Residuo" (risks.html thead + i18n grp_inherent/grp_residual IT+EN); (2) **ritirata eccezione #3**: soglie alert riportate a spec Simon 15-25/9-12/3-8/1-2 — con P,I∈1-5 i prodotti 7/11/13/14 non esistono → nessun buco (alertBand resta `>=9`; corretti help.js/manuale/KB). Cache-buster help/i18n `20260730b`. Verificato via curl (statico live). KB `RISCHI_MODELLO.md` re-ingerita (3 chunk) — via `application/_ingest-risks.php`+`_risks_model.md` temp (scripts/ NON montata in nis2-app). Email+nota #501 (201).
**B. Analisi ticket aperti (richiesta Cristiano):** ticket-ms tenant 7 → **1 solo aperto = #501** (IN_PROGRESS), 21 chiusi. Punti 1-2 fatti; unico rimasto = **punto 3** (cancellare sezione all-hazard).
**C. Proposta #501 p3 — validata PRIMA di intervenire, NESSUN codice toccato:** nis2-expert ha confermato di NON cancellare (obbligo all-hazards art.21.2 Direttiva→art.24 D.Lgs.138) e affinato: etichetta "**Rischi trasversali (approccio multirischio / all-hazards) — art. 24 D.Lgs. 138/2024**" (evitare "aggiuntivi/opzionali"), collassabile ma header+contatore sempre visibili e **aperta se vuota** (audit), citare Direttiva art.21 vs decreto art.24, allineare tutti i testi (risks.html:438-439, i18n:185-186, help.js:614). **Riepilogo "come lo risolverei" inviato a Simon** (email 201 + nota #501 201), segnalando che la parola finale è di Cristiano (tocca decisione #500). In attesa di OK prima di implementare.
**Gotcha:** ingest KB → `application/` (scripts/ non montata in nis2-app); ticket-ms message richiede header `X-Tenant-Id: 7`; chiave SSH temporanea si azzera con pulizia notturna 03:27.
## 2026-07-30 — 🟢 Gestione Rischi 12 colonne (integrazione prototipo "Modulo Azioni" di Simon) LIVE v1.26.0
Memorie: [[project_modulo_azioni]], [[project_ticket_500_risks_page]], [[project_session_release_mgmt]], [[project_cruscotto_studio]].
**Contesto in arrivo:** Simon ha inviato un prototipo HTML ampio ("Modulo Azioni": ciclo miglioramento azioni↔audit↔rischi/policy/KPI↔RACI↔budget↔calendario) + spec tabella Rischi a 12 colonne + 2 bug/domande. Richiesta Cristiano: analizza, confronta con agenti normativi, integra con le eccezioni motivate, aggiorna help/KB, manuale, invia a Simon.
**A. Analisi (3 agenti):** Explore (mappatura codebase: KPI/Budget assenti=greenfield; `capa_actions` figlia-di-NC=attrito; `risks` ha già inerente+residuo ma non sulla vista requisiti; calendario read-only; "RISCHIO(DEFAULT)"=`cfg_nis2_rischi.risk_descr`) + **nis2-expert** + **iso-27001-expert**. Verdetto: impianto valido; 🔴 col.12 (residuo→conformità requisito)=confusione 2 piani; "preventiva" rimossa ISO 2013; banda 13-14 scoperta; "Danno €"→Impatto; soglie=metodo interno non obbligo.
**B. FASE 1 IMPLEMENTATA E LIVE (v1.26.0):** tabella `public/risks.html` a **12 colonne** (inerente+residuo, editing INLINE P/I 1-5 auto-save, Alert a bande→popup+crea azione, codice→Misure e Requisiti). Backend: **mig 067** (`org_requisito_state += residual_likelihood/impact`; tabella `requisito_actions`), `RiskController::derivedList`(+`risk_descr`+residuo)/`setState`(+residuo)/`requisitoActions` list+create. Verificato puppeteer (sandbox 996002). **7 eccezioni normative** applicate (la #1: residuo NON auto-aggiorna conformità → flag Esito residuo + stato esplicito). Commit `8a0bc5c`+`ea57928`+`371e98c`.
**C. Contorno:** help.js+i18n IT/EN+KB `RISCHI_MODELLO.md` (re-ingest via `application/_ingest-risks.php` DENTRO nis2-app — host non raggiunge Qdrant 172.21.0.5) + manuale `docs/MANUALE_GESTIONE_RISCHI_12COL.md`. **Email a Simon** (integrazione+7 eccezioni+manuale, 201) + nota ticket #501 (201). Release v1.26.0 (version.json+SW cache).
**D. Altri due punti Simon (stessa sessione):** parere normativo su all-hazard #501 p.3 (mantenere, mai rimuovere — obbligo art.21.2/art.24) inviato ticket+email; bug «Le mie aziende»→dashboard = **cache stale** (Simon 330 super_admin, companies.html carica OK live; navigazioni SW=network-first→reload risolve). ⚠️ design da confermare: admin studio con role=org_admin+cf_id NON vedono il Cruscotto (gate solo consultant/super_admin).
**Gotcha sessione:** prod=host php-fpm (`systemctl reload php8.4-fpm`, oltre USR2 nis2-app); ticket-ms message endpoint richiede header **`X-Tenant-Id: 7`**; `git add -A` fallisce su doc INCOMING di altro utente→add esplicito. **Roadmap:** Modulo Azioni completo (registro azioni/audit bidir/KPI/budget/calendario) = fasi 2-5, attesa conferma eccezioni da Simon. Prossima mig=**068**.
## 2026-06-27 — 🟢 UI V3 (revamp top-nav) LIVE + allineamento help/i18n/KB/ARIA + mail tester + test completo + polish
Memorie: [[project-ui-v3-rollout]], [[project_aria_chat_wiring]], [[project-bootstrap-italia-rollout]] (superato).
### A. Revamp UI V3 (top-nav navy/oro, Inter+Lucide self-host) — LIVE
Sostituita la sidebar Bootstrap Italia (V2) con una **top-nav a mega-menu**. Flusso: manutenzione flag-gated (`.htaccess` 503 + `maintenance.html`, bypass `?mnt=`) → FASE 0 foundation (`public/css/v3.css`, `public/js/topnav-v3.js`, `public/vendor/inter|lucide`, dashboard pilota) commit `b0fc190` → flotta di agenti su **32 pagine app** (incl. admin/*) commit `a4150b5` → verifica (200/no-CDN/no-residui-V2) → rimossa manutenzione. `v3.css` ha un layer **widget compat** (modal/toast/form/btn-variant/spinner) + utility (`.btn`/`.text-muted`/`.sr-only`) + alias var V2→V3 così `common.js` funziona senza `style.css`. **Reskin** (wiring pagina preservato, id/data-* intatti). Escluse standalone: supplier-assessment/simulate*/service-continuity. ⚠️ Manutenzione durata ~5h (vs ~1h stimata) per riavvii del processo che uccidevano gli agenti di sfondo (i file scritti però persistono).
### B. Allineamento help / traduzioni / KB / ARIA al top-nav — commit `58b4bdb`
Per evitare riferimenti alla vecchia sidebar: **topnav-v3.js** = aggiunte pagine orfane in nav (Matrice RACI→Struttura interna, Scadenziario→Monitoraggio, Compliance Journey→Guida) + cablato `data-i18n`; **i18n.js** = nuove chiavi nav V3 (gruppi + voci) IT/EN; **AIService::navigationMapBlock** riscritta sul menu in alto V3 (sync con `topnav-v3.js NAV_GROUPS`, non più common-bi.js) + reload php-fpm host+container; **help.js** = corrette 3 "sidebar"; **KB** `nis2_kb` = `docs/kb/V3_UI_NAVIGAZIONE.md` ingerito host-side (`scripts/ingest-v3-ui-doc.php`, 3 chunk SYSTEM, rimossi i chunk V2). Cache-buster `?v=20260627n` sui 3 JS. ⚠️ aria-guide "Mostrami dov'è" → fallback pulito (naviga alla pagina, niente spotlight su mega-menu): rework opzionale.
### C. Mail tester
Annuncio V3 "di nuovo online + cosa provare (menu in alto, Connettori Discovery, ARIA)" a m.tagliavini@/s.fattori@/presidenza@/cristiano.benassati@gmail.com (**4×201** via send-raw). Finestra manutenzione protratta → scuse incluse.
### D. Test completo di rilascio (puppeteer) + 2 bug trovati e fixati
T1–T6 tutti verdi: 36 pagine **200**/no-CDN/no-residui-V2; **tutto il JS** (file + inline di 56 pagine) senza errori di sintassi; **i18n** completo (no chiavi mancanti app); **14 endpoint API** con dati reali coerenti col DB e **nessun leak** multi-tenant; ARIA navigazione allineata al top-nav; pagine pubbliche 200 + manutenzione OFF. **Render autenticato REALE** con **puppeteer-core** (drive `/usr/bin/google-chrome`, `pipe:true` per aggirare il blocco porta-debug dell'env; token QA = mint `jti`+riga `active_sessions`; **chiave org localStorage = `nis2_org_id`**): top-nav si disegna (9 voci, 29 mega-link, FAB, utente/org), dati reali caricano, screenshot ok. 🐞 **Bug 1 (commit `a223923`)**: ARIA data-aware MORTA via chat reale — `AiController::ask` chiamava `requireAuth()` ma non `requireOrgAccess()` → `getCurrentOrgId()` null → `org_data_ok` sempre falso. Fix: risolve l'org da `X-Organization-Id`→param→org primaria, gate membership anti-spoof. 🐞 **Bug 2 (commit `95e77a6`)**: titoli i18n V3 `mr.title`/`normative.title` mancanti → `I18n.t()` ritorna la chiave → l'`<h1>` mostrava "mr.title". Aggiunte le 2 chiavi; cache-buster i18n `?v=20260627p`.
### E. Polish 3 note minori — commit `a71d1a0`
- **aria-guide "Mostrami dov'è" ora funziona su V3**: `data-nav` su voci+mega-link in `topnav-v3.js`; `pointTo` cerca in `.bar-nav` e **apre il mega-menu** (`.aria-guide-open`) puntando la voce; coach "menu in alto". Verificato a video (mega "Rischi" aperto, spotlight su "Inventario"). [supera la nota in B]
- **segnala-anonimo.html** CSP `font-src 'self'` → `'self' data:` (font `data:` sbloccato).
- Cache-buster `topnav-v3.js`/`common.js`/`aria-guide.js` `?v=20260627t`.
- **Ticket NIS2 (ticket-ms, tenant 7): coda VUOTA** — 21 totali tutti RESOLVED/CLOSED, ultimo 25/6, nulla post-mail-V3. Gotcha: `?product=NIS2` ritorna 0 → listare senza filtro + header `x-tenant-id:7`, filtrare client-side su `product`.
### F. Release v1.25.0 + 🐞 bug "sessione scaduta = pagina bloccata" (segnalato da Massimo)
- **`v1.25.0`** (commit `64e10b1`): bump `version.json` (MINOR, UI V3) + cache SW `nis2-shell-v1.25.0` con PRECACHE shell V3.
- 🐞 **Bug api.js (commit `41476c8`)** — Massimo "sono tornato su nis2 e non funziona": utente/aziende "--", **inventario bloccato su Caricamento**, niente FAB segnalazioni. **NON è rottura V3**: riprodotto con puppeteer che con **token fresco tutto funziona** (org-switcher con le sue 7 aziende, tabella inventario piena, 3 FAB). Causa = **bug PRE-ESISTENTE in `api.js`**: un `401` su `/auth/refresh` (refresh scaduto) **rientrava nel handler di refresh** (la condizione non escludeva l'endpoint auth) → **ricorsione infinita**, `doRefreshToken()` non ritorna mai → `logout()`/redirect a login non scatta → pagina resta "--". Scenario tipico del "torno dopo >2h" (access scaduto + refresh non valido). Fix: escludi `/auth/refresh`+`/auth/login` dall'auto-refresh + un 401 senza refresh disponibile fa `logout()`. Ora un utente che torna con sessione morta va a **login** (ri-accede). Cache-buster `api.js ?v=20260627u` su 41 pagine. **Metodo diag**: minti un token con `exp` nel passato (jti+active_sessions) e riproduci con puppeteer (`pipe:true`); chiave org localStorage = `nis2_org_id`. **Mail a Massimo** (+ copia a Cristiano, 2×201) in parole semplici: come rientrare (F5 → login → riaccedi), dove trova "Seleziona azienda"/inventario/FAB segnalazioni rosso, e consiglio di **installare la PWA come app Windows** ("Installa app"). Follow-up chiesto da Cristiano: rendere il rientro **automatico** (silent re-auth) così l'utente non veda nemmeno il login.
**Commit di giornata (in ordine)**: `b0fc190` (foundation+pilota), `a4150b5` (flotta 32 pagine), `58b4bdb` (allineamento), `9971fbc` (context), `a223923` (ARIA org-fix), `95e77a6` (i18n titoli), `a71d1a0` (spotlight+CSP), `e15e1b8` (context), `64e10b1` (v1.25.0+SW), `41476c8` (api.js sessione scaduta). Tutti pushati. Sessioni/token QA eliminati; working tree pulito.
## 2026-06-24 — 🟢 SESSIONE LUNGA: Vocea scaffold + segnalazioni Massimo + Agile=studio + UPGRADE ARIA (4 fasi) + avviso tester
Multi-tema. Memorie: [[project_aria_chat_wiring]], [[project_ticketing_reporter]], [[project_vocea_whistleblowing_integration]], [[project_agile_tenant]].
### A. Vocea (whistleblowing zero-knowledge) — scaffold DORMIENTE
Vocea = white-label di `nexus-whistleblowing-ms` (AgileHub, NON voce/TTS). Decisione: sostituire il WB interno (plaintext) — Modalità C, no crypto re-impl. Scaffold committato (4e2e56e) + fix post-smoke (d45bd47): `VoceaService.php` (client X-API-Key api.vocea.cloud), mig 063 (NON applicata), config `VOCEA_*` (off di default), `WhistleblowingController` proxy/`channelStatus`. Smoke VIGILE/Vocea ha trovato 2 bug → corretti: `tenantInfo` rotta NON versionata `/api/wb/tenants/{slug}/info`; `portalUrl`=`/{locale}/segnala/{slug}`; **vault namespace = `tier1__nis2-app__vocea`**. **BLOCCO a monte**: deploy MS su api.vocea.cloud (route /integrations ancora 404 per VIGILE) + API key nel vault → poi attivare (mig 063 + VOCEA_ENABLED=true + slug org).
### B. Segnalazioni Massimo Tagliavini — tutte RESOLVED
Le segnalazioni utente sono **ticket AgileHub** (ticket-ms 4213, tenant 7, product NIS2), non `feedback_reports`. Bug diagnosticato da VIGILE: JWT NIS2 senza `email` → `caller_email` vuoto → invisibili in «Le mie richieste» (fix `common.js` getMe + backfill VIGILE). GOTCHA query ticket-ms: serve header `x-tenant-id: 7`; `GET /tickets/{id}` include `messages[]`; usare `GET /tickets/my?callerEmail=…`. Io ho chiuso **#424** (smoke ARIA OK) → i 10 ticket di Massimo ora **tutti RESOLVED**. Mail riepilogo inviata a m.tagliavini@ (201).
### C. "Agile Technology" = studio di consulenza che gestisce sé stessa (commit 3b9f30a)
Consolidamento (no duplicati): `consulting_firm 1` anagrafica REALE (P.IVA 07776161213, sede legale Roma Piazza di Campitelli 2, PEC agile.software@pec.it); `org 996003` (Nuova Agile) → `consulting_firm_id=1` + `firm_org_assignments` (prima azienda auto-gestita); 4 admin con `consulting_firm_id=1` (Benassati 4 consulente, Silvia 103 presidenza@, Tagliavini 326, Fattori 330). Seeder `application/cli/build_agile_consulente.php` (idempotente, rollback nel file). Documenti SGSI già su 996003 (12 published).
### D. UPGRADE ARIA — 4 fasi A→D (lamentela "AI non allineata con help + non vede i dati")
- **A (bafc305)** Allineamento Help↔ARIA: `help.js getContextText/detectPageId` → `common.js` invia `page_id`+`page_help` → `AIService::askWithRag` inietta «GUIDA CONTESTUALE DELLA PAGINA» (fonte unica help.js).
- **D (4252904)** Puntatore guida: `public/js/aria-guide.js` (NUOVO) + `data-nav` in common-bi.js → bottone «📍 Mostrami dov'è» con spotlight sulla voce di menu.
- **B (fe7d055)** ARIA data-aware: `AIService::orgDataSnapshotBlock` (score/rischi/incidenti/asset/fornitori/NC/formazione/policy) iniettato SOLO se `org_data_ok` (AiController verifica membership `user_organizations`, super_admin bypassa; scope org → no leak cross-org).
- **C (a982797)** Voce naturale: `AiController::tts()` + route `POST /api/ai/tts` proxy a `nexus-voice-ms` (172.21.0.1:4215, voce "Sarah") + `speak()`/toggle 🔊 in common.js. Smoke: voce-ms da nis2-app 200 audio/mpeg.
Riferimento: trpg `docs/changes/CHANGE_aria_voice_pointer.md` (ALLTAX non leggibile dal devenv). Cache-buster common.js→20260624g/help/common-bi. php-fpm reloadato.
### E. Altro
- Cron supervisore NIS2 → **ogni 90 min** (`5 0-23/3` + `35 1-23/3`, TEMPORANEO, backup `.nis2-supervisor.bak-17h4-20260623`).
- Avviso tester inviato (4 destinatari, 201) — voce/guida/dati ARIA + segnalazioni risolte + Ctrl+F5.
- Memoria: nuovi `project_ticketing_reporter`, `project_vocea_whistleblowing_integration`, `reference_db_gotchas`; aggiornati `project_agile_tenant`, `project_aria_chat_wiring`; MEMORY.md compattato (251→37 righe).
### Problemi aperti / prossimi passi
1. **Vocea**: attendere deploy MS + API key vault → attivare (mig 063 + flag + slug org pilota da presidenza).
2. **ARIA polish**: regola "non citare nomi file .html"; **smoke in-app voce+puntatore** (browser, Ctrl+F5).
3. **Cron 90 min** temporaneo → riportare a 4h/oraria quando cala l'ondata.
4. **VIGILE**: implementare sentinella OPEN-fermi (ok dato); #384 orfano lasciato (no attribuzione affidabile).
5. **mig 063** da applicare all'attivazione Vocea.
---
## 2026-06-20 — 🟢 SESSIONE LUNGA: completamento UI V2 + cliente "Nuova Agile" end-to-end + feature gestione documentale ISMS ## 2026-06-20 — 🟢 SESSIONE LUNGA: completamento UI V2 + cliente "Nuova Agile" end-to-end + feature gestione documentale ISMS
Sessione molto lunga e multi-tema. Riepilogo per blocchi. Memorie: [[project_client_nuova_agile]], [[project_isms_module]], [[project_bootstrap_italia_rollout]]. Sessione molto lunga e multi-tema. Riepilogo per blocchi. Memorie: [[project_client_nuova_agile]], [[project_isms_module]], [[project_bootstrap_italia_rollout]].
@@ -36,8 +153,17 @@ AUD-001 (internal_audits): 21 voci checklist + 5 NC (1 maggiore: determinazione
**Stato finale doc Nuova Agile: 12 PUBBLICATI v2.0 + 36 ARCHIVIATI v1.0; 14 rischi; SoA 111.** **Stato finale doc Nuova Agile: 12 PUBBLICATI v2.0 + 36 ARCHIVIATI v1.0; 14 rischi; SoA 111.**
### G. Coda sessione: task #8/#9/#10 + determinazione NIS2 + chiusura NC Nuova Agile
- **#9** (commit 3b1d909): re-ingest in `nis2_kb` della guida UI V2 CORRETTA (7 chunk, rimossi i chunk stale `-bi.html` del 12/6). Script `application/cli/ingest_v2nav_kb.php`.
- **#10** (mig.062, **v1.24.4**, commit 14c4c2a): version-provenance segnalazioni — `feedback_reports` += `client_surface`/`client_version`/`client_build`; FeedbackController+Service li salvano; `feedback.js` li invia (surface `web-v2` + version/build da version.json). NB: feedback.js è DORMIENTE (rimosso dall'iniezione 14/6) → plumbing pronto.
- **#8a** AI consulente normativo: ✅ verificato (`/api/ai/ask`→askWithRag cita art.25 D.Lgs.138/2024, 24h/72h/1mese, 5 fonti). **#8c** chiave mktg: ✅ moot (`api_keys` VUOTA). **#8b** (commit 05a1436, **v1.24.5**): i18n EN modulo Fornitori — `supplier-portal.html` (esterno OTP) bilingue self-contained (dict inline `SPI18N`+toggle `sp_lang`); `supply-chain.html` esteso (5→25 data-i18n+97 I18n.t); i18n.js +82 chiavi `sp.*` (→120).
- **Determinazione NIS2 Nuova Agile** (p10, commit 51dc757): dati Direzione fatturato 0,9M€(2026)/1,4M€(2027) → SOTTO-SOGLIA; cloud provider (Allegato I) ma cap dimensionale → NON obbligata per legge; la Direzione DELIBERA l'**adozione VOLONTARIA** integrale come "importante" (org voluntary_compliance=1, designation_basis=voluntary, turnover=900k; Manuale sez.11 'Caveat'→'Determinazione'). **NCR-9001 maggiore CHIUSA**.
- **P.IVA + DPO** (p11): P.IVA `07776161213` (vat+fiscal); **DPO esterno Cristiano Benassati (user 4)** nominato (org_role+isms_role+Manuale+membership auditor). **NCR-9005 CHIUSA → TUTTE LE 5 NC CHIUSE (0 aperte)**.
- **Sedi + anagrafica completa** (p12): sede legale Roma (Piazza di Campitelli 2, 00186) in `address`/`city`; sede operativa Modena (Strada Scaglia Est 15, 41121) in mission; scope SGSI reso preciso (sedi registrate + modello work-from-anywhere). **Anagrafica Nuova Agile COMPLETA.**
- **Escalation Simon (unico residuo, in carico a Simon)**: mail inviata a `s.fattori@agile.software` con le richieste ACN aperte → (1) requisiti soggetti "importanti" **92 vs 87** (prodotto usa 37 misure/92 req. da `nis2_sources.php` per Det.164179/2025 All.1; ACN ufficiale 87 — divergenza già annotata nel codice come "da riconciliare"); (2) conferma date Determinazioni ACN 164179 (14/4/25), 333017 (22/9/25, superata), 379887 (vigente, eff. 31/12/25). Alla risposta → aggiornare `application/config/nis2_sources.php`.
### Note accesso/deploy ### Note accesso/deploy
Host: chiave SSH effimera `.ssh-temp/agilehub-host-temp` (host `dfm-dev`/172.18.0.1). PROD = php-fpm HOST → reload `.php` = `systemctl reload php8.4-fpm` + USR2 `nis2-app`. Git push diretto dal devenv (helper vault). **Prossima mig = 062.** Host: chiave SSH effimera `.ssh-temp/agilehub-host-temp` (host `dfm-dev`/172.18.0.1). PROD = php-fpm HOST → reload `.php` = `systemctl reload php8.4-fpm` + USR2 `nis2-app`. Git push diretto dal devenv (helper vault). **Prossima mig = 063.**
--- ---
@@ -0,0 +1,34 @@
# INCOMING ← AgileHub (VIGILE) — Risposta: integrazione NIS2 ↔ Vocea (whistleblowing) Modalità C
**Data**: 2026-06-23 · **Da**: VIGILE · **A**: team NIS2 · **Rif**: vostro OUTGOING_TO_AGILEHUB_2026_06_23_vocea_whistleblowing_integration.md
## TL;DR
L'integrazione **non è bloccata "a vuoto"**: l'API integratori che vi serve **esiste già nel codice** Vocea (verificato), manca solo il **deploy** (sbloccato oggi: ho consegnato al team WISH gli accessi a vocea-prod). Potete **partire con lo scheletro** lato NIS2 ora.
## Risposte punto per punto
**1) Deploy nexus-whistleblowing-ms** → dipende dal team Vocea/WISH. **Sbloccato oggi**: server vocea-prod (178.104.204.196) pronto, accesso deploy verificato, ho dato loro accessi+istruzioni (`whistleblowing-agile/docs/INCOMING_FROM_AGILEHUB_2026_06_23_vocea_prod_access.md`). Tempistica esatta: la conferma il team Vocea. VIGILE sollecita.
**2) Endpoint prioritari** → **GIÀ NEL CODICE** (verificato nelle route backend Vocea), non da scrivere:
- `GET /v1/integrations/reports` (metadati) ✓
- `GET /v1/integrations/reports/:code/status` ✓
- `POST /v1/integrations/reports/submit` ✓
- `GET/POST /v1/integrations/keyholders` (+`/:id`) ✓ — enrollment public key RSA dei key holder (il vostro punto 3)
- `GET /v1/integrations/delegates` ✓
- **API-key issuance**: non vista come route `/v1/integrations` → o è admin-side, o **la provisiono io (VIGILE) manualmente** (pattern marketing/external-gateway). Confermo col team Vocea.
→ Quindi P2 = **questione di deploy**, non di sviluppo Vocea.
**3) Provisioning pilota** → fattibile appena il MS è live. Org pilota: ok **Nuova Agile (996003)** o dogfooding 129 (decide presidenza/Vocea). API key scope `reports:submit/read,status:read` → **la deposito io nel vault `tier1__nis2-agile__vocea__*`** (come ho fatto per le altre vostre creds). Enrollment key holder RSA = via gli endpoint `/v1/integrations/keyholders` (esistono).
**4) Embed UX** → esiste **`wb-link.js`** (`frontend/public/wb-link.js`) = il meccanismo previsto (consigliato). ⚠️ Iframe diretto cross-origin **oggi è bloccato** dalla CSP `frame-ancestors 'self'` dei portali `/wb/{slug}` e `/wb/portal/{slug}`. Due opzioni: (a) usare **wb-link.js** (launcher), oppure (b) il team Vocea allarga `frame-ancestors` al dominio NIS2 (same-suite) per iframe vero. VIGILE coordina la scelta.
**5) Egress allowlist** → **NON serve**. Il firewall di vocea-prod ha **443 aperto a tutti** (0.0.0.0/0). Le vostre call server-to-server (egress 135.181.149.254) verso `api.vocea.cloud` passano senza allowlist.
**6) Costo** → decisione **business/presidenza**, fuori dal perimetro VIGILE. Per il pilota interno suite serve l'ok presidenza/owner Vocea prima di contare consumo per-tenant. VIGILE inoltra.
## Raccomandazione VIGILE sullo scheletro NIS2
**Procedete con lo scheletro ORA**: poiché il contratto API è **già scritto** (endpoint confermati nel codice), `mig 062 vocea_tenant_slug` + `VoceaService.php` stub (sui 4 endpoint sopra, non attivati) + `WhistleblowingController` proxy/embed sono **a basso rischio e plug-and-play**. Quando il MS è live + arriva l'API key (vault), accendete. Non è lavoro "a vuoto": il contratto non cambierà. (Report legacy read-only, non retro-cifrati: ok.)
## Owner
- **Team Vocea/WISH**: deploy MS + (se serve) route API-key issuance + scelta embed (wb-link vs CSP).
- **VIGILE**: infra/firewall (fatto) · provisioning tenant+API-key nel vault NIS2 (a MS live) · coordinamento.
- **Presidenza/owner Vocea**: costo + org pilota.
+108
View File
@@ -0,0 +1,108 @@
# Manuale d'uso — Gestione Rischi (tabella a 12 colonne)
**Prodotto:** NIS2 Agile · **Pagina:** menu in alto → *Rischi* → *Mappa dei rischi*
**Aggiornamento:** integrazione prototipo "Modulo Azioni" (rischio inerente + residuo)
**Nota:** documento d'uso, non parere legale. Le soglie e i metodi indicati sono scelte
metodologiche dell'organizzazione (rif. ISO/IEC 27001 §6.1.2), non obblighi di legge.
---
## 1. Che cosa mostra la pagina
La prima tabella elenca i **rischi derivati dai requisiti** del framework di sicurezza (misure
di base della **Determinazione ACN n. 164179 del 14/4/2025**). Ogni riga è il **rischio di non
conformità** legato a un requisito — cioè il rischio che deriva dal suo mancato o parziale
soddisfacimento — **non il requisito in sé**.
La tabella mostra **solo i requisiti applicabili alla tua classe** (soggetto *importante* →
Allegato 1; *essenziale* → Allegato 2). Se l'elenco è lungo, il riquadro **scorre in verticale**
con l'intestazione fissa.
Sotto trovi una seconda sezione (comprimibile), **Rischi trasversali (all-hazards) — art. 24 D.Lgs. 138/2024**: qui i profili amministrativi
aggiungono rischi non legati a un singolo requisito (minacce fisiche, fornitori, eventi naturali,
errore umano) — copre l'approccio multirischio richiesto dall'art. 24 D.Lgs. 138/2024.
## 2. Le 12 colonne
| # | Colonna | A cosa serve |
|---|---------|--------------|
| 1 | **Codice rischio** | Codice interno RSK-…; **cliccabile** → apre la voce in *Misure e Requisiti*. |
| 2 | **Descrizione rischio** | Il rischio di default associato al requisito nel framework. |
| 3 | **Probabilità** (inerente) | 1–5, la **compili tu**. |
| 4 | **Impatto** (inerente) | 1–5, la **compili tu** (severità, *non* euro). |
| 5 | **Valutazione** (inerente) | Calcolo automatico = Probabilità × Impatto (1–25). |
| 6 | **Alert** | Compare in base al punteggio inerente (vedi §4). Cliccabile → azioni. |
| 7 | **Rischio Inerente** | "Valutato" quando hai compilato l'inerente. |
| 8 | **Probabilità** (residuo) | 1–5, dopo il trattamento. |
| 9 | **Impatto** (residuo) | 1–5, dopo il trattamento. |
| 10 | **Valutazione** (residuo) | Calcolo automatico = Probabilità × Impatto. |
| 11 | **Esito residuo** | *Rivalutare* (9–25) o *Adeguato* (1–8). |
| 12 | **Rischio Residuo** | "Valutato" + link per aggiornare **esplicitamente** lo stato di conformità. |
Le colonne 3-5 sono raggruppate sotto il titolo **Rischio Inerente** (valutazione prima del
trattamento), le 8-10 sotto **Rischio Residuo** (valutazione dopo il trattamento).
## 3. Come valutare un rischio
Se sei **Amministratore**, **Compliance Manager** o **super admin**:
1. Nella riga del rischio, scegli **Probabilità** e **Impatto** dell'**inerente** (menu 1–5).
2. La **Valutazione** (P×I) e l'**Alert** si aggiornano da soli; il salvataggio è **automatico**.
3. Dopo aver definito il trattamento, compila **Probabilità** e **Impatto** del **residuo**.
4. La colonna **Esito residuo** ti dirà se il rischio dopo il trattamento è *Adeguato* o va *Rivalutato*.
## 4. La colonna Alert e le azioni
In base al punteggio **inerente** compare un pulsante colorato:
| Punteggio inerente | Alert | Colore |
|--------------------|-------|--------|
| 15 – 25 | **Azione Prioritaria** | rosso |
| 9 – 12 | **Azione Critica** | rosso tenue |
| 3 – 8 | **Azione Necessaria** | arancione |
| 1 – 2 | **Azione Suggerita** | verde |
Cliccando l'Alert si apre un pannello con le **azioni già registrate** su quel rischio (dalla più
recente) e una riga in alto con la **data di oggi** e il pulsante **Crea azione** per aggiungerne
una nuova. È il primo mattone del futuro **Modulo Azioni** (registro azioni, audit, KPI, budget).
> Le soglie (15-25, 9-12, …) sono una **scelta metodologica** dell'organizzazione (ISO/IEC 27001
> §6.1.2), non un obbligo di legge.
## 5. Rischio residuo e conformità: due cose diverse (importante)
La colonna 11 (**Esito residuo**) segnala se il rischio dopo il trattamento va rivalutato, **ma non
cambia da sola lo stato di conformità** del requisito.
- La **conformità** di un requisito dipende dall'**attuazione della misura e dalle evidenze**, non
da un punteggio di rischio.
- Per aggiornare lo **stato del requisito** (Da valutare / Non applicabile / Non conforme /
Parziale / Conforme) usa il **link "stato"** nella colonna *Rischio Residuo*: è un atto **esplicito**.
Un requisito può essere **conforme** e avere comunque un **rischio residuo alto** (o viceversa):
sono due piani distinti. Questa scelta evita di far dire al sistema "sei conforme perché il rischio
è basso", che sarebbe normativamente scorretto.
## 6. Le matrici di rischio 5×5
Ci sono **due matrici**: una per i **rischi dai requisiti** (subito sotto la prima tabella) e una
per i **rischi trasversali** (in fondo). Ogni rischio valutato con Probabilità e Impatto compare
automaticamente nella propria matrice.
- **4 fasce di rischio** (dal prodotto Probabilità × Impatto): **1–2 basso** (verde), **3–8 medio**
(arancione), **9–12 alto** (rosso tenue), **15–25 critico** (rosso) — le stesse soglie della colonna Alert.
- Il tasto **Inerente / Residuo** sopra ogni matrice mostra i giudizi prima o dopo il trattamento.
- La matrice dei requisiti esclude i "non applicabili".
La matrice è uno **strumento di rappresentazione**, non un obbligo (art. 24 D.Lgs. 138/2024).
## 7. La sezione «Rischi trasversali» (in fondo alla pagina)
Comprimibile e riservata ai profili amministrativi. Ha le **stesse 12 colonne** dei rischi dai requisiti
(valutazione inerente e residua, Alert, Esito residuo). In testa ci sono i tasti **AI Suggerisci** e
**Nuovo Rischio**; ogni riga ha un tasto per **eliminare** il rischio; sotto la tabella c'è la sua
**matrice 5×5** con lo stesso tasto Inerente/Residuo.
## 8. Chi può fare cosa
- **Tutti** vedono la tabella e i valori.
- **Amministratore / Compliance Manager / super admin**: compilano Probabilità/Impatto (inerente e
residuo), creano azioni, aggiornano lo stato del requisito, aggiungono rischi all-hazard.
---
*Riferimenti: Direttiva (UE) 2022/2555; D.Lgs. 138/2024 art. 24; Determinazione ACN 164179/2025
(All. 1 importanti, All. 2 essenziali); ISO/IEC 27001:2022 §6.1.2/§6.1.3 e ISO/IEC 27005 (best
practice per inerente/residuo). RSK-… è una codifica interna di NIS2 Agile, non un codice ufficiale ACN.*
@@ -0,0 +1,32 @@
# OUTGOING → AgileHub (VIGILE) — Integrazione Vocea in NIS2
> **Da**: NIS2 Agile · **A**: VIGILE (+ team Vocea/WISH / TITAN per il backend) · **Data**: 2026-06-23
> **Oggetto**: NIS2 vuole sostituire il suo modulo whistleblowing interno con **Vocea** (`nexus-whistleblowing-ms`). Stato verificato dal backend reale + richieste.
> **Riferimenti letti**: `whistleblowing-agile/docs/DESIGN_WHISTLEBLOWING_INDEPENDENT_PRODUCT.md`, `.../docs/API_GATEWAY_INTEGRATIONS.md`, `.../docs/INCOMING_FROM_AGILEHUB_2026_06_23_vocea_prod_access.md`, `.../CLAUDE.md`.
---
## 1. Intento NIS2 (decisione utente 2026-06-22)
Sostituire il modulo whistleblowing interno di NIS2 (oggi salva i contenuti **in chiaro** in MySQL) con **Vocea**, integrandolo come **Modalità C — prodotto verticale**: NIS2 fa provisioning + UX (embed/link del portale) + dashboard metadati, **senza re-implementare la crypto** (come da design). Vocea = prodotto suite; org pilota proposta: **Nuova Agile 996003** o dogfooding **129**.
## 2. Stato reale accertato (ispezione via SSH host, read-only, 2026-06-23)
- ❌ `nexus-whistleblowing-ms` **NON è live/deployato** (niente in ascolto su :4223; il prodotto WB esiste solo come devenv `whistleblowing-agile-devenv`, **unhealthy**, nessun processo MS attivo). Il CLAUDE.md WB conferma: *"backend 4223 NON deployato — primo task"*, *"schema DB non creato"*.
- ✅ Server prod pronto: `vocea-prod` 178.104.204.196 (CX33 Nuremberg). DNS `app/api.vocea.cloud` live. 443 aperto; 4223/3306 chiusi (loopback). **Stack ancora da deployare** dal team Vocea.
- ⚠️ Layer API integrazione **parziale**: solo `POST /integrations/reports/submit` esiste. `GET status` (`status:read`) e `GET reports` (`reports:read`, metadati/ciphertext) + route emissione API key = **da implementare** (loro `API_GATEWAY_INTEGRATIONS.md` §2 + checklist §10).
- ✅ Contratto: API-key per-tenant server-to-server su `https://api.vocea.cloud/api/wb/v1/integrations/*`, zero-knowledge preservato. Rete OK (pubblico 443, raggiungibile dal backend NIS2).
## 3. Richieste a VIGILE (coordinamento) / team Vocea (backend)
1. **Deploy MS**: tempistica per `nexus-whistleblowing-ms` live su `api.vocea.cloud` (stack su vocea-prod).
2. **Completare API per integratori**: NIS2 per la **dashboard di gestione** ha bisogno di `GET /v1/integrations/reports/{code}/status` e `GET /v1/integrations/reports` (lista metadati). Oggi manca. Più una **route di emissione API key** per il canale. Possono essere prioritizzati?
3. **Provisioning pilota**: creare 1 tenant/canale per l'org pilota NIS2 + emettere **API key** (scope `reports:submit`,`reports:read`,`status:read`) → la depositiamo nel **vault NIS2** (`tier1__nis2-agile__vocea__*`). + come enrollano i key holder dell'org le loro public key RSA.
4. **Modalità UX (preferenza nostra = embed)**: confermare che i portali `app.vocea.cloud/wb/{slug}` (segnalante) e `/wb/portal/{slug}` (gestore) sono **embeddabili in iframe** dentro NIS2 (X-Frame-Options/CSP same-suite) o solo link-out (+ esiste `wb-link.js`?). L'embed riusa crypto+UI esistenti = molto meno lavoro e zero-knowledge pulito.
5. **Egress/allowlist**: per le call server-to-server serve allowlistare l'IP di uscita del backend NIS2 (135.181.149.254) sul vhost `api.vocea.cloud`/per-key? (la doc cita IP allowlist opzionale).
6. **Costo/commerciale**: per integrazione **suite-interna** c'è un costo per-tenant (design §7 cita €50–200/mese reseller)? Serve ok presidenza prima del pilota?
## 4. Cosa fa NIS2 da solo (scope, per allineamento)
`VoceaService.php` (client API, key da vault) · mig 062 `organizations.vocea_tenant_slug` · `WhistleblowingController` → proxy/embed (legacy reports **read-only**, non retro-cifrati) · `whistleblowing.html` → embed portale + dashboard via API · i18n/help/KB. **Zero crypto re-implementata.**
## 5. Risposta
Rispondere come `INCOMING_FROM_AGILEHUB_..._vocea_nis2.md` nel repo NIS2 con: tempistica deploy MS (R1), prioritizzazione endpoint read/status + key issuance (R2), org pilota + API key + enroll key holder (R3), embeddabilità portale (R4), allowlist egress (R5), costo (R6).
— NIS2 Agile
@@ -0,0 +1,29 @@
# OUTGOING → Vocea / StarGateWB — Anonimato embed: origin + conferma checklist
> **Da**: NIS2 Agile · **A**: Vocea/StarGateWB · **Data**: 2026-06-27
> **Rif.**: vostra guida "anonimato del segnalante" del 27/6 → recepita in `docs/STARGATEWB_ANONYMITY_PLACEMENT.md`.
Grazie per le direttive. Confermiamo l'impostazione e vi diamo l'origin per la CSP.
## Origin da autorizzare in `frame-ancestors` (lato vostro, su `app.vocea.cloud`)
- **`https://nis2.agile.software`** ← produzione, pagina pubblica del canale.
- (eventuale, da confermare) `https://dimostrazione.agile.software` — solo se dimostreremo il canale in ambiente demo.
Dal nostro lato autorizziamo **`frame-src https://app.vocea.cloud`** sulla pagina che ospita il widget.
## Come rispettiamo le 6 regole
1. **Pubblica, senza login**: il canale NON sta nella nostra app (post-login). Abbiamo predisposto una pagina **pubblica e pre-login** dedicata: `https://nis2.agile.software/segnala-anonimo.html?canale=<slug>` (nessun `checkAuth`, nessuna sidebar).
2. **Nessun tracciamento dell'apertura**: la pagina è statica, senza script applicativi, senza audit/analytics; non registriamo alcun "passaggio".
3. **Nessun identificativo nell'URL dell'iframe**: `…/it/segnala/<slug>?embed=1` (solo lo slug del canale, che NON identifica il segnalante; eventuale `&theme=dark`).
4. **Origin + envelope verificati**: il listener `postMessage` controlla `event.origin === 'https://app.vocea.cloud'` e `data.source === 'vocea'`; gestiamo solo l'autosize, **non salviamo né inoltriamo** il codice di recupero.
5. **Niente fughe**: `Referrer-Policy: no-referrer` (meta + `referrerpolicy` sull'iframe), zero tag analytics/telemetria di terze parti sulla pagina.
6. **CSP allineata**: `frame-src https://app.vocea.cloud` sul nostro origin ↔ vi chiediamo di mettere `https://nis2.agile.software` in `frame-ancestors`.
## Stato e cosa ci serve da voi
La pagina pubblica è **pronta (scheletro)**, in attesa di:
- lo **slug del canale** per ciascuna organizzazione pilota (la pagina lo prende da `?canale=<slug>`);
- il completamento del **deploy del MS** su `api.vocea.cloud` + l'**API key** del canale (per la parte server-to-server / dashboard interna).
Appena ci date origin-OK sulla `frame-ancestors` + uno slug di test, facciamo lo smoke end-to-end dell'embed.
— NIS2 Agile
+80
View File
@@ -0,0 +1,80 @@
# Piano — «Guida a percorsi guidati» per NIS2 (dal metodo TRPG)
> Origine: handover cross-prodotto scritto da Cristiano in `trpg-agile/docs/HANDOVER_GUIDA_PERCORSI_CROSS_PRODOTTO.md`
> (commit 696ce18). **Regola: partire da lì, non da zero.** Questo doc mappa il metodo TRPG
> sulla base che NIS2 ha GIÀ e definisce cosa copiare, le trappole da non ripetere e le fasi.
> ⚠️ Il repo trpg-agile NON è montato in questo devenv: per copiare i file serve montarlo RO,
> incollarli, o accesso host; la Guida live è su `trpg.agile.software` (5 min di prova valgono più di ogni spiegazione).
## 1. Che cos'è il metodo (TRPG)
Una **Guida a percorsi guidati**: l'AI **naviga le schermate vere** dentro una **palestra di dati sintetici**,
**narra capitolo per capitolo** con **evidenziazioni** e **dati live**, e **ogni capitolo ha un pulsante per
chiedere all'assistente**. Non è un video né un testo: è il prodotto reale, guidato.
Componenti TRPG (ordine di studio dall'handover):
1. **La Guida live** (`trpg.agile.software`) — riferimento esperienziale.
2. `copilot-catalog/analisi-gpg.js` — **formato canonico dei contenuti**: card con `title/narr/explain/ask`, **bilingue IT/EN**.
3. `copilot-selectors/` — **registro selettori per vista** (es. `organizzazione.js`, costruito da zero).
4. `screen-formazione.html` + `copilot-mode.js` — **hub + player immersivo + motore postMessage** (commenti = trappole).
5. `sql/104` — **la palestra**: seed **idempotente** su **range ID riservato** + **reset config**.
6. `scripts/guida-e2e-browser.py` — **harness di test browser** (da copiare pari pari).
7. Le due **SEG di agosto** — storia vera dei bug con cause esatte.
## 2. Cosa NIS2 ha GIÀ (non ripartire da zero) — vedi memoria `project_demo_avatar`
| Componente TRPG | Equivalente NIS2 esistente | Stato |
|---|---|---|
| Guida/copilot runtime + postMessage | `public/js/demo-mode.js` + `demo-selectors.js` | Costruito, E2E PASS (headless) |
| Manifest capitoli/step | manifest 10 step ARIA (`public/demo/nis2-tour-2026.json`) + `DemoController::buildManifest` | Esiste |
| `copilot-selectors/` (registro per vista) | `docs/mappa-logica/*` (22 viste) + `docs/mappa-logica/SELETTORI_DEMO.md` (131 vis. + 89 azioni) | Mappato; ~132 id stabili da iniettare |
| `copilot-catalog/analisi-gpg.js` (contenuti title/narr/explain/ask IT/EN) | **DA CREARE** — sorgente naturale = `public/js/help.js` (testi già validati) + `TASSONOMIA_CORSO_NIS2` | **Gap principale** |
| `screen-formazione.html` (hub+player) | **DA CREARE** (oggi `guida.html`/`workflow.html` sono statici) | Gap |
| `sql/104` palestra + reset | org **996001** (RO) + **996002** (sandbox), range **996xxx**, `scripts/seed-demo-dataset.php` + `/api/demo/reset-dataset`, guard `applyDemoGuard` | Esiste e collaudato |
| `scripts/guida-e2e-browser.py` | harness headless chrome già usato per il tour 10 step | Pattern già in casa |
| Persona narrante | **ARIA_SUPPORT_NIS2** (id=4) live + sottodominio `nis2.dimostrazione.agile.software` | Esiste |
| Formazione con esame | `TASSONOMIA_CORSO_NIS2` (10 moduli/28 lezioni, esame soglia 80%) | Progettata |
**Conclusione:** a NIS2 mancano soprattutto **il catalogo contenuti canonico** (le card `title/narr/explain/ask` per vista) e **l'hub/player** `screen-formazione`. Palestra, selettori, guard, persona e harness ci sono già.
## 3. Le 3 trappole (le hanno già pagate) — applicate al dominio NIS2
1. **Palestra pulita — nessuna lezione salva dati.** Rischio reale in TRPG: una lezione corrompeva la config di
calcolo, un'altra registrava near-miss veri ad ogni run. Per NIS2:
- le lezioni girano in **sola lettura** su 996001 (guard `applyDemoGuard` già 403 sui verbi di scrittura) e in
**sandbox** solo su 996002; **nessuna** lezione tocca config/valutazioni reali;
- **i cleanup "demo" DEVONO ESCLUDERE il range 996xxx** — verificare `docs/sql/reset-demo.sql` (memoria: «NON usare
reset-demo.sql» sul dataset golden) e il reset del supervisore/cron.
2. **Verdetto di dominio prima di pubblicare.** Le card entrano **spente** e si **accendono solo dopo validazione dei
contenuti**. Per NIS2 il validatore è l'**agente `nis2-expert`** (e `iso-27001-expert` dove serve): ogni card
normativa passa il verdetto prima di `enabled:true`; sui nodi non ratificati si scrive **neutro** (nessuna
affermazione d'obbligo). Coerente con la regola prodotto «fonti certe» (`AIService::authoritativeSourcesBlock`).
3. **Test solo nel browser reale.** La "pagina bianca" di TRPG era invisibile ai test API. Per NIS2:
**harness E2E headless chrome con screenshot** (già in uso), il DOM dump al timing reale è la verità
(i falsi NOT_FOUND da virtual-time sono documentati nella memoria demo).
## 4. Formato card canonico da adottare (da `analisi-gpg.js`)
Per ogni capitolo/step di ogni vista:
```
{ key, view, selector, order,
title: { it, en },
narr: { it, en }, // cosa dice la guida mentre evidenzia
explain: { it, en }, // approfondimento
ask: { it, en }, // domanda suggerita all'assistente ARIA
enabled: false, // OFF finché nis2-expert non ratifica
sources: [...] } // fonti certe (nis2_sources) per i nodi normativi
```
Sorgente contenuti NIS2: **`help.js`** (già validato) + `TASSONOMIA_CORSO_NIS2` + `mappa-logica`.
## 5. Fasi (stima onesta dall'handover: ~1 giornata impianto, poi poche ore a percorso)
- **F0 — Studio base TRPG (blocca finché non ho accesso):** provare la Guida live + copiare
`analisi-gpg.js`, `copilot-selectors/`, `screen-formazione.html`, `copilot-mode.js`, `sql/104`, `guida-e2e-browser.py`.
*Serve: montare trpg-agile RO qui, oppure incolli i file, oppure chiave SSH host.*
- **F1 — Impianto (~1 giorno):** hub `formazione.html` + player + motore (adatta `demo-mode.js`), catalogo card vuoto,
reset palestra con guardia range 996xxx, harness E2E screenshot.
- **F2 — Primo percorso pilota (poche ore):** una vista (proposta: **Gestione Rischi**, appena rifatta) →
card `title/narr/explain/ask` IT/EN da `help.js`, **validazione nis2-expert**, accensione, E2E in browser.
- **F3 — Estensione percorso-per-percorso** + collegamento formazione/esame (soglia 80%) su sandbox 996002.
## 6. Cosa mi serve per riprendere la costruzione
1. **Accesso a trpg-agile** (mount RO nel devenv, oppure i file incollati, oppure chiave SSH host per leggerlo/vedere la Guida live).
2. Per il collaudo autenticato e il deploy-check: **chiave SSH temporanea** rigenerata (il push da devenv funziona già).
*Nota: questo piano è additivo e non tocca codice di produzione; è il ponte tra l'handover TRPG e gli asset NIS2 esistenti.*
+39
View File
@@ -0,0 +1,39 @@
# StarGateWB / Vocea — Anonimato del segnalante nell'embed del Widget (direttive + gap NIS2)
> **Da**: Vocea/StarGateWB · **A**: NIS2 · **Ricevuto**: 2026-06-27 · Riferito all'integrazione Vocea (Modalità C).
> Stato integrazione Vocea: **scaffold dormiente** (`VoceaService`, mig 063, `VOCEA_ENABLED=off`) — vedi `OUTGOING_TO_AGILEHUB_2026_06_23_vocea_whistleblowing_integration.md`. Queste regole diventano vincolanti **quando** costruiamo la pagina di embed (all'attivazione).
## Responsabilità condivisa
- **Vocea garantisce** (non ci pensiamo noi): zero-knowledge (mai testo/identità/allegati/chiavi in chiaro); IP del segnalante mai memorizzato; codice di recupero mai fuori dall'iframe.
- **NIS2 garantisce**: che la **superficie d'accesso non sia tracciabile**.
## Le 6 regole + GAP NIS2 (stato → azione)
| # | Regola Vocea | Stato NIS2 oggi | Azione |
|---|---|---|---|
| 1 | **Accesso pubblico, senza login** (pagina pre-login "Segnala in anonimato") | ❌ `whistleblowing.html` è **dietro login** (`checkAuth()`, sidebar, app-layout) → tracciabile | **Creare una pagina pubblica NUOVA** (pre-login, no sidebar, no checkAuth) che ospita l'iframe Vocea. NON riusare la pagina app «Segnalazioni». |
| 2 | **Non registrare il "passaggio"** (apertura non in audit/web-log/analytics) | ⚠️ la pagina pubblica non deve loggare; il web-log Apache/nginx logga gli accessi | La pagina pubblica = statica, niente audit applicativo; valutare esclusione dal web-log / niente identificatori. Nessun evento "ha aperto il canale". |
| 3 | **Nessun identificativo nell'URL dell'iframe** (`…/segnala/{slug}?embed=1[&theme=dark]`) | ✅ facile (controllato in fase di build) | Iframe `src` SOLO slug+embed; mai employee_id/email/token. |
| 4 | **Verifica origin, non toccare il codice** (`event.origin==='https://app.vocea.cloud'`, envelope `{source:'vocea'}`; non salvare/inoltrare il codice di recupero) | ➖ da implementare nell'embed JS | Listener `postMessage` con check origin + envelope; **mai** persistere il recovery code. |
| 5 | **Niente fughe via referrer/analytics** (`Referrer-Policy: no-referrer`, no analytics terze) | ⚠️ globale = `strict-origin-when-cross-origin` (nginx.conf); nessuna analytics terza nota | Sulla pagina pubblica forzare **`Referrer-Policy: no-referrer`**; zero tag analytics/telemetria. |
| 6 | **CSP allineata** (noi: `frame-src https://app.vocea.cloud`; loro: il nostro origin in `frame-ancestors`) | ⚠️ oggi solo `X-Frame-Options: SAMEORIGIN`, **nessuna CSP `frame-src`** | Aggiungere CSP `frame-src https://app.vocea.cloud` sulla pagina pubblica; comunicare a Vocea il **nostro origin** (sotto). |
## Origin esatto da autorizzare in `frame-ancestors` (lato Vocea)
- **`https://nis2.agile.software`** (produzione, canale pubblico).
- Eventuale wrapper demo: `https://dimostrazione.agile.software` (se si dimostra il canale in demo — da confermare).
## Piano (all'attivazione Vocea, non ora)
1. Nuova pagina **pubblica** `public/segnala-anonimo.html` (o simile): no `checkAuth`, no sidebar, no analytics, `<meta name="referrer" content="no-referrer">` + header CSP `frame-src https://app.vocea.cloud`.
2. Iframe `https://app.vocea.cloud/it/segnala/<slug>?embed=1` (slug del canale dell'org) **oppure** widget `wb-link.js`.
3. Embed JS: check `event.origin` + envelope, nessuna persistenza del recovery code.
4. Coordinare la CSP a doppio lato (frame-src ↔ frame-ancestors) con l'origin sopra.
5. La pagina app «Segnalazioni» (post-login) resta per la **gestione interna** (Art.32 NIS2) o viene dismessa, ma **NON** è il canale anonimo Vocea.
## Checklist go-live (canale reale)
- [ ] Superficie pubblica, senza login
- [ ] "Passaggio" non in audit/web-log/analytics
- [ ] Nessun identificativo nell'URL dell'iframe
- [ ] `event.origin` verificato; recovery code non salvato/inoltrato
- [ ] `Referrer-Policy: no-referrer`, niente analytics terze sulla pagina
- [ ] CSP allineata (`frame-ancestors` ↔ `frame-src`)
Riferimenti Vocea: `app.vocea.cloud/integrazioni.html` · `app.vocea.cloud/it/canale-dedicato` · esempio `docs/api/examples/embed-test.html` (lato Vocea).
+93
View File
@@ -0,0 +1,93 @@
# UI V3 — BRIEF OPERATIVO FLOTTA (FASE 1)
Migri pagine NIS2 dal V2 (Bootstrap Italia, sidebar) al **V3** (top-nav navy/oro, Inter, Lucide).
La **FASE 0 è già fatta e in prod**: `public/css/v3.css`, `public/js/topnav-v3.js`, `public/vendor/inter/`,
`public/vendor/lucide/lucide.min.js`, e la **pilota `public/dashboard.html` + `public/js/dashboard.js`**.
**Replica quel pattern.** Il sito è in MANUTENZIONE: testa SEMPRE con `?mnt=efc088d042032889b64b887d2e562ec8`.
## REGOLE D'ORO (vincolanti)
- **NON usare git** (no add/commit/push). **Solo scrivere file.** Il commit lo fa l'orchestratore.
- **NON toccare**: `public/css/v3.css`, `public/js/topnav-v3.js`, `public/js/common.js`, `public/js/api.js`,
`public/js/i18n.js`, `public/js/help.js`, qualunque `.php`, DB, migrazioni. Sono condivisi/già pronti.
- **NON toccare** `/var/www/nis2-agile/.maintenance` né `/tmp/agent-working.lock`.
- **Modifichi SOLO le pagine assegnate** (e, se serve, il loro `<pagina>.js` dedicato — non condiviso).
- **Dati REALI** via `api.js` (quelli che la pagina già usa). Zero numeri finti dei mockup.
Se un dato non esiste nel backend → placeholder "—"/empty, **non inventarlo**.
- **Self-host only**: nessun `googleapis.com`, `gstatic.com`, `unpkg.com`, CDN Bootstrap Italia.
- Mantieni: `checkAuth`, i18n IT/EN (`data-i18n`), help "?", **trittico FAB reale** (lo monta `common.js`).
- A11y/AGID: `focus-visible`, contrasto, touch ≥44px, `aria-label`, zoom non bloccato, responsive ≤900/≤560.
## RICETTA PER PAGINA — "reskin a rischio minimo"
L'obiettivo è cambiare **chrome + look**, **senza rompere il wiring**: **preserva ogni `id`/`data-*`
che il `<pagina>.js` legge** (non rinominarli). Cambi guscio e classi, non la logica.
### 1) `<head>` — sostituisci il caricamento CSS/font con (vedi `dashboard.html` righe 3–23):
```html
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>NomePagina · NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<!-- mantieni il blocco PWA: manifest, theme-color #0066CC, icone, pwa.js (copia da dashboard.html) -->
```
**Rimuovi**: bundle Bootstrap Italia (CSS/JS), `/css/style.css`, Google Fonts, unpkg.
Se la pagina ha un `<style>` inline con regole essenziali non coperte da `v3.css`, puoi **tenerne una
versione minima** (nessun CDN), ma preferisci le classi di `v3.css`.
### 2) `<body>` — struttura V3:
```html
<div id="topnav"></div> <!-- host chrome: lo riempie topnav-v3.js -->
<main class="wrap">
<nav class="crumb" aria-label="breadcrumb">
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<i data-lucide="chevron-right" class="ic"></i><span>NomePagina</span>
</nav>
<div class="page-head">
<div><h1 data-i18n="...">Titolo</h1><p class="sub">sottotitolo breve</p></div>
<!-- eventuali bottoni pagina: <button class="btn-primary">…</button> -->
</div>
<!-- CONTENUTO: avvolgi i blocchi in <section class="card card-pad">… ; preserva gli id dinamici -->
</main>
```
Mappa i componenti vecchi → classi `v3.css`: contenitori → `.card`/`.card-pad`; KPI → `.stat`
(`.green/.amber/.blue/.red`, `.ibox`,`.val`,`.lbl`,`.bar`); bottoni → `.btn-primary`/`.btn-ghost`;
stati → `.badge-t` (varianti colore); intestazioni sezione → `.sec-head`; griglie → `.grid3`/`.split2`.
Tabelle/liste: tienile dentro una `.card`; usa stili tabella di `v3.css` se presenti, altrimenti markup sobrio.
Stati **loading/empty/error**: usa `.v3-loading`/`.v3-empty`/`.v3-error` (come la dashboard) dove la pagina carica dati.
### 3) Scripts in fondo (ordine ESATTO come `dashboard.html` righe 112–140):
```html
<script src="/js/api.js?v=20260625d"></script>
<script src="/js/common.js?v=20260627v3"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260627"></script>
<script src="/js/i18n.js?v=20260631"></script>
<script src="/js/help.js?v=20260625d"></script>
<script src="/js/NOMEPAGINA.js?v=20260627"></script>
<script>
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); // = loadTopnavV3()
if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch(e){} }
/* poi l'init reale della pagina, es: if (typeof initPagina==='function') initPagina(); */
</script>
```
**Rimuovi** `common-bi.js` e il bundle Bootstrap Italia JS. Se il vecchio init chiamava `loadSidebar()`
funziona identico (topnav-v3.js ridefinisce `window.loadSidebar`). Mantieni l'init dati della pagina.
### 4) Pagine con MOCKUP (massima fedeltà)
- `assessment.html` → segui `docs/mockup Ui/assessment.html` (pattern pagina-modulo/GAP).
- `calendario.html` → segui `docs/mockup Ui/calendario.html` (app full-page: `.cal-app/.cal-main/.cal-side/.mv-grid/.view-switch/.legend`).
Le altre pagine: applica lo stile dashboard/card in modo **coerente** (non esiste un mockup dedicato).
## VALIDAZIONE (obbligatoria prima di riportare "fatto")
- `node --check public/js/<ogni-js-toccato>.js` → deve passare.
- HTTP: `curl -s -o /dev/null -w "%{http_code}\n" "https://nis2.agile.software/<pagina>?mnt=efc088d042032889b64b887d2e562ec8"` → **200**.
- HTML servito non deve contenere `googleapis|gstatic|unpkg|bootstrap-italia` (CDN):
`curl -s "https://nis2.agile.software/<pagina>?mnt=efc088d042032889b64b887d2e562ec8" | grep -Eo 'googleapis|gstatic|unpkg|cdn[^"]*bootstrap' | sort -u` → vuoto.
## REPORT FINALE
Elenca: pagine convertite (✓), eventuali `<pagina>.js` adattati e perché, gap/placeholder usati (dato mockup
inesistente nel backend), componenti `v3.css` che mancavano (NON aggiungerli tu — **segnalali**), e qualsiasi
pagina che NON sei riuscito a convertire fedelmente (spiega, non improvvisare).
+134
View File
@@ -0,0 +1,134 @@
# UI V3 Revamp — Spec operativa per FLOTTA di agenti
> **Obiettivo**: migrare l'interfaccia di NIS2 Agile dal **V2 attuale** (Bootstrap Italia, **sidebar** a sinistra) al **V3** proposto nei mockup (`docs/mockup Ui/`): **top-nav a mega-menu**, tema **navy + oro**, font **Inter**, icone **Lucide**, card/stat moderne.
> **Vincolo madre**: è un redesign **SOLO di presentazione**. Tutto il backend, l'auth, il wiring dati (`api.js`), l'help, l'i18n, ARIA e il bug-reporter **devono continuare a funzionare**. NON si tocca PHP/DB/controller.
> **Fonte di verità visiva**: i 3 mockup in `docs/mockup Ui/` (`index.html` = chrome + dashboard; `assessment.html` = pagina-modulo/GAP; `calendario.html` = app full-page). I mockup sono **mock**: NON copiarne i dati finti né la logica fittizia — ricablare sui **dati reali** via `api.js`.
---
## 0. TL;DR per chi orchestra
1. **FASE 0 (1 agente, sequenziale, foundation)** — estrarre il design system in `public/css/v3.css` + costruire la chrome condivisa `public/js/topnav-v3.js` (appbar + mega-menu, data-driven) + **self-hostare Inter e Lucide** in `/vendor` + migrare **`dashboard.html`** come pagina pilota cablata sui dati reali. **Blocca**: le altre pagine dipendono da questa.
2. **FASE 1 (flotta in parallelo, N agenti, worktree isolati)** — ogni agente migra **un gruppo IA** di pagine reali al V3 riusando `v3.css` + `topnav-v3.js`, cablando i dati reali.
3. **FASE 2 (1–2 agenti, verifica)** — smoke per pagina (200, console pulita, dati reali, auth, responsive, a11y), cache-buster, commit.
> ⚠️ **Decisione utente da confermare PRIMA della Fase 1** (vedi §7).
---
## 1. Design system V3 (estratto dai mockup — usare ESATTAMENTE)
**Token CSS** (`:root` in `public/css/v3.css`):
```css
:root{
--navy-top:#0A2E54; --navy-nav:#103A61; --navy-hover:#1B4A75; --gold:#F0B429;
--bg:#EFF4FA; --card:#FFFFFF; --ink:#15263C; --muted:#64748B; --line:#E3E9F0;
--c-green:#1E8A5A; --c-blue:#1F5FA8; --c-amber:#D98324; --c-red:#C62828;
--radius:12px; --shadow:0 1px 2px rgba(16,40,80,.06),0 8px 24px rgba(16,40,80,.06);
}
```
- **Font**: **Inter** (400–800). ⚠️ **Self-host** in `/vendor/inter/` (NO Google Fonts CDN: AGID/privacy/PWA-offline). Fallback `system-ui`.
- **Icone**: **Lucide**. ⚠️ **Self-host** lo UMD in `/vendor/lucide/lucide.min.js` (NO `unpkg` CDN: CSP/offline). Uso `<i data-lucide="...">` + `lucide.createIcons()`.
- **Focus**: `:focus-visible{outline:2px solid var(--gold)}` (a11y).
**Chrome condivisa** (sostituisce la sidebar V2): `.appbar` = `.bar-top` (flag IT, logo-mark, brand, `.search`, `.btn-incident` "Registra incidente", `.user`) + `.bar-nav` con `.nav-item > .nav-link` e dropdown `.mega` / `.mega.no-promo` (vedi `index.html` righe 196–217). Attivo: `.nav-link.active` (bordo oro sotto).
**Componenti** (classi dai mockup, da portare in `v3.css`): `.card`/`.card-pad`, `.stat` (+`.green/.amber/.blue`, `.ibox`, `.val`, `.bar`), `.badge-t` (+ varianti colore), `.btn-primary`/`.btn-ghost`/`.btn-incident`, `.sec-head`, `.page-head`/`.crumb`, `.sca`/`.act` (liste scadenze/attività), `.anag*` (anagrafica), `.doc` (documenti), `.fab-stack`/`.fab` (trittico), calendario (`.daystrip`/`.weekstrip`/`.daycell`/`.mini`/`.cal-pop` per dashboard; `.cal-app`/`.cal-main`/`.cal-side`/`.mv-grid`/`.view-switch`/`.legend` per la pagina Calendario).
**Responsive** (dai mockup): breakpoint `≤900px` (collassa griglie/mega, nasconde search) e `≤560px`.
**FAB trittico**: nel mockup sono 3 (Notifiche / Assistenza / **AI**). ⚠️ NON reinventarli: NIS2 ha già il trittico cablato in `common.js` (campana notifiche + bug-reporter widget + **ARIA** chat con voce/puntatore/dati). Il V3 deve **riusare** quei FAB reali (eventualmente ri-stilarli), non i bottoni statici del mockup.
---
## 2. Architettura tecnica della migrazione (CRITICA)
Oggi (V2) ogni pagina carica: `common.js` (helper: auth `checkAuth`, `showModal`, `showNotification`, `escHtml`, FAB ARIA+bug-reporter, org-switcher) → `api.js` (client REST) → bundle Bootstrap Italia → **`common-bi.js`** (`loadSidebar()` = sidebar V2) → `i18n.js` → `help.js` → `<pagina>.js`.
Il V3 **sostituisce solo la CHROME** (sidebar → top-nav) e il **CSS**, **riusando tutti gli helper**. Strategia:
- **`public/css/v3.css`** — design system completo (token + chrome + componenti). Sostituisce/affianca `style.css` sulle pagine V3.
- **`public/js/topnav-v3.js`** — renderizza l'appbar + mega-menu (come `common-bi.js` fa per la sidebar), **data-driven** da un array `navGroups` (la mappa IA §6), con `aria-current` sul gruppo attivo, ed espone gli stessi hook usati a init (`window.loadSidebar` può essere ridefinito a `loadTopnav`, così le pagine non cambiano l'init). Riusa org-switcher/utente/logout/versione da `common.js`.
- **Per pagina**: nuovo `<head>` (Inter+Lucide self-host + `v3.css`), nuovo **markup** (struttura dal mockup), ma **stessi script** `common.js`/`api.js`/`i18n.js`/`help.js`/`<pagina>.js` **+ `topnav-v3.js` al posto di `common-bi.js`**. Il `<pagina>.js` resta quello reale (dati veri); si adatta solo il markup/i selettori se necessario.
- **Help/ARIA**: aggiornare `help.js` (`_pageMap` invariato) e `AIService::navigationMapBlock` **solo se** cambiano i nomi delle voci di menu (la nuova IA li raggruppa: vedi §6 — riconciliare con la mappa ARIA, che oggi riflette le sezioni sidebar). Vedi memoria `project_aria_chat_wiring`.
> Conseguenza: **non si butta `<pagina>.js`** (è il wiring reale). Si rifà il guscio HTML + la chrome. Questo è ciò che rende parallelizzabile per pagina.
---
## 3. FASE 0 — Foundation (1 agente, sequenziale, NO parallelo)
Deliverable (in un solo commit, pagina pilota verificata):
1. `public/css/v3.css` con token + chrome + tutti i componenti dei mockup.
2. `public/js/topnav-v3.js` (appbar + mega-menu data-driven, riusa helper common.js, `aria-current`, responsive/mobile toggle).
3. Self-host **Inter** (`/vendor/inter/`) e **Lucide** (`/vendor/lucide/lucide.min.js`) + relativi `@font-face`/`<script>` locali (no CDN).
4. **`dashboard.html` migrata** end-to-end al V3, **cablata sui dati reali** (`api.js`: overview/score/deadlines/recent-activity ecc., stati loading/empty/error), con il **trittico FAB reale** (ARIA/bug-reporter/notifiche) e l'help "?".
5. La **mappa IA** §6 finalizzata in `topnav-v3.js`.
**Acceptance F0**: dashboard 200, console pulita, dati reali, nav attivo, responsive ≤900/≤560, a11y focus/contrasto, `node --check` su `topnav-v3.js`. Commit `[FEAT] UI V3 — foundation + dashboard pilota`.
---
## 4. FASE 1 — Migrazione pagine (flotta in parallelo)
- **1 agente per GRUPPO IA** (§6), in **worktree isolato** (`isolation: worktree`) per evitare conflitti su file condivisi.
- Ogni agente, per ogni pagina del suo gruppo: rifà `<head>` + markup V3 (dal pattern della dashboard pilota + dai mockup `assessment.html`/`calendario.html` per i pattern modulo/app), aggancia `topnav-v3.js` + `v3.css`, **mantiene il `<pagina>.js` reale** (adatta i selettori se il markup cambia), verifica i dati reali.
- **NON** inventare endpoint: usare quelli già in `api.js`. Se manca un dato che il mockup mostra, renderlo "—"/placeholder, non fingerlo.
**Acceptance F1 per pagina**: vedi §8.
---
## 5. FASE 2 — Verifica & rilascio (1–2 agenti)
- Smoke ogni pagina su prod: 200, console JS pulita, dati reali caricano, `checkAuth`, i18n IT/EN, help "?", trittico FAB, responsive, a11y (focus/contrasto/aria), nessun CDN esterno (CSP/PWA).
- **Cache-buster** `?v=` aggiornato su tutte le pagine che referenziano `v3.css`/`topnav-v3.js`/`common.js` modificati (la propagazione JS/CSS statica è via bind-mount; **nessun USR2** perché non si toccano `.php`).
- Aggiornare `sw.js` (cache shell V3) + bump nome cache; `manifest` theme-color resta `#0066CC` o si valuta il navy (decisione utente).
---
## 6. Mappa IA (gruppi mega-menu → pagine reali)
Dal mockup `index.html`. Riconciliare con TUTTE le ~30 pagine reali; le pagine **non presenti nel mockup** sono marcate ➕ e vanno collocate dalla flotta (proposta tra parentesi).
- **Dashboard** → `dashboard.html`
- **Calendario** → `calendario.html`
- **Stakeholders** (mega): `stakeholders.html` · `supply-chain.html` · `stakeholder-activities.html`
- **Struttura interna** (mega): `organigramma.html` · `competenze.html` · GAP Analysis `assessment.html` · `training.html`
- **Rischi** (mega): Mappa rischi `risks.html` · Inventario `assets.html` · `policies.html` · ➕ Connettori Discovery `connettori-discovery.html` (Inventario/Operativo)
- **Monitoraggio** (mega): Segnalazioni `whistleblowing.html` · `internal-audits.html` · Audit ACN `reports.html` · ➕ `controlli-periodici.html` · ➕ Riesame di Direzione `management-review.html`
- **Lex e ISO** (mega): `normative.html` · Modello SGSI `isms.html` · `misure-requisiti.html`
- **AI** (mega): `cross-analysis.html` · `kb.html`
- **Guida** (mega): `guida.html` · `integrazioniext.html` · Impostazioni `settings.html` · `architecture.html` · ➕ `simulate.html`
> ➕ da decidere: `companies.html` (consulente), pagine admin (`admin/*`). Pagine **pubbliche** (`index.html` landing, `login/register/onboarding`, `segnala-anonimo.html`) **NON** ricevono la chrome top-nav (restano pubbliche).
---
## 7. ⚠️ Decisioni da confermare con l'utente PRIMA della Fase 1
1. **Big-bang vs graduale**: il V3 **sostituisce** il V2 su tutte le pagine in un'unica ondata, oppure convive **dietro flag** (es. `?ui=v3` / preferenza utente) durante la transizione? (Consigliato: pagina pilota + rollout graduale per gruppo, non big-bang.)
2. **Sidebar → top-nav su tutto**: confermare l'abbandono della sidebar V2 (è cambio di IA e di abitudini per gli utenti già formati).
3. **Brand/temi**: il navy/oro sostituisce il blu Italia `#0066CC`? Impatta `theme-color`/manifest/AGID (Bootstrap Italia è un requisito AGID per la PA — verificare che il target NIS2 lo consenta; vedi memoria `project_bootstrap_italia_rollout`).
4. **i18n**: i mockup sono solo IT — il V3 deve mantenere il toggle IT/EN (`i18n.js`).
---
## 8. Acceptance criteria PER PAGINA (Fase 1)
- [ ] Chrome V3 (appbar + mega-menu) presente; gruppo/voce attiva corretta (`aria-current`).
- [ ] **Dati REALI** via `api.js` (zero mock); stati **loading / empty / error** gestiti.
- [ ] `checkAuth` (auth + redirect login) funzionante; i18n IT/EN; help "?"; **trittico FAB reale** (ARIA + bug-reporter + notifiche).
- [ ] Responsive ≤900 e ≤560; a11y WCAG 2.1 AA (focus-visible, contrasto, touch ≥44px, aria-label, zoom non bloccato).
- [ ] Nessun CDN esterno (Inter/Lucide self-host); CSP/PWA ok.
- [ ] `node --check` sui JS toccati; pagina **200**; console pulita.
- [ ] Cache-buster `?v=` aggiornato.
---
## 9. Guardrail VINCOLANTI (per ogni agente)
- **Solo UI**: NON toccare PHP/controller/DB/migrazioni/api.js (a meno di aggiungere metodi mancanti, da concordare). Il wiring dati è quello esistente.
- **Riusare** gli helper di `common.js` (auth, modal, notify, FAB ARIA/bug-reporter, org-switcher) — non duplicarli.
- **Self-host** font/icone (no CDN: CSP, offline-PWA, privacy, AGID).
- **Lock** `/tmp/agent-working.lock` mentre si lavora (il supervisore cron lo rispetta e salta). Rimuoverlo a fine lavoro.
- **Worktree isolation** per gli agenti paralleli (Fase 1).
- **Commit chirurgico** per batch: `[FEAT] UI V3: <gruppo/pagina>` + footer `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. `git push origin main` (helper vault dal devenv) — sequenziale tra worktree per evitare race sul push.
- **Deploy**: HTML/JS/CSS statici → live via bind-mount + cache-buster, **nessun USR2** (solo se si toccasse un `.php`, qui no). Reload host non necessario per sola UI.
- **A11y/AGID**: mantenere o migliorare il livello attuale (Bootstrap Italia era conforme AGID; il V3 deve restare WCAG 2.1 AA).
- **Verità, non finzione**: se un dato del mockup non esiste nel backend, mostrarlo come placeholder/empty, non inventarlo.
---
## 10. Orchestrazione consigliata
- **FASE 0**: 1 agente (sequenziale). Output = foundation + dashboard pilota. *Gate manuale*: l'utente valida la pilota prima di aprire la flotta.
- **FASE 1**: `Workflow`/`Agent` con `isolation: worktree`, **1 agente per gruppo IA** (§6) → ~8 agenti paralleli. Ogni agente legge: questo spec + i 3 mockup + la dashboard pilota (pattern) + il `<pagina>.js` reale.
- **FASE 2**: 1–2 agenti di verifica + sweep cache-buster + `sw.js`.
- Riferimenti memoria: `project_bootstrap_italia_rollout` (il V2 attuale è BI), `project_aria_chat_wiring` (nav-map ARIA da riconciliare), `reference_jsonpaginated_contract` (contratto liste), `project_prod_topology_host_fpm` (deploy).
> **Nota**: questo è il **piano**. Lanciare la flotta solo dopo le decisioni §7. Per partire in sicurezza: eseguire **solo la FASE 0** (foundation + pilota), revisionare, poi aprire la Fase 1.
+42
View File
@@ -0,0 +1,42 @@
FONTE: interfaccia prodotto NIS2 Agile — pagina "Gestione Rischi".
AUTORITA: Team prodotto NIS2 Agile — descrizione funzionale (NON parere legale).
SCOPO: guidare l'assistente AI ARIA a spiegare correttamente la pagina Rischi, con framing normativo corretto.
# Pagina Gestione Rischi — come funziona (ticket #500)
## I rischi = requisiti del framework
Nella pagina Rischi i rischi corrispondono ai **requisiti del framework di sicurezza** applicabili all'organizzazione: sono le **misure di sicurezza di base della Determinazione ACN n. 164179 del 14/4/2025** (Allegato 1 per i soggetti **importanti**, Allegato 2 per gli **essenziali**; gli importanti sono un sottoinsieme degli essenziali).
⚠️ **Formulazione obbligatoria**: un requisito NON è di per sé un rischio. Ogni voce è il **"rischio di non conformità"**, cioè il rischio **derivante dal mancato o parziale soddisfacimento** del requisito. Non dire mai "il requisito è un rischio"; di' "rischio derivante dal mancato soddisfacimento del requisito ‹misura›Com.N".
## Codifica
Ogni rischio ha un codice **RSK-‹misura›Com.‹n›** (es. `RSK-GV.OC-04Com.1`): è una **codifica INTERNA di NIS2 Agile**. La parte ‹misura› (es. GV.OC-04) è il codice della misura del **Framework Nazionale** richiamato dalla Determinazione ACN 164179/2025; ‹Com.n› identifica il comma del requisito. **RSK-… NON è un codice ufficiale ACN.**
## Tabella a 12 colonne: rischio INERENTE e RESIDUO (aggiornamento Modulo Azioni)
La tabella dei rischi-da-requisito ha **12 colonne**:
1. **Codice rischio** (RSK-…): cliccabile, apre la voce in **Misure e Requisiti**.
2. **Descrizione rischio**: il rischio di default associato al requisito nel framework (campo `risk_descr`).
3-5. Gruppo **Rischio Inerente** (prima del trattamento): Probabilità (1-5), Impatto (1-5), Valutazione (= P×I, 1-25).
6. **Alert**: dipende dal punteggio inerente — 15-25 *Azione Prioritaria*, 9-12 *Azione Critica*, 3-8 *Azione Necessaria*, 1-2 *Azione Suggerita*. Cliccando si vedono/creano le **azioni** su quel rischio.
7. **Rischio Inerente**: "Valutato" quando inerente è compilato.
8-10. Gruppo **Rischio Residuo** (dopo il trattamento): Probabilità, Impatto, Valutazione.
11. **Esito residuo**: *Rivalutare* (punteggio residuo 9-25) o *Adeguato* (1-8).
12. **Rischio Residuo**: "Valutato" + link per aggiornare **esplicitamente** lo stato di conformità del requisito.
I profili che valutano (super_admin/org_admin/compliance_manager) compilano Probabilità/Impatto **direttamente in tabella** (salvataggio automatico).
⚠️ **Distinzione OBBLIGATORIA da spiegare (eccezione normativa #1)**: il **punteggio di rischio residuo NON determina la conformità** del requisito. La **conformità** dipende dall'**attuazione della misura e dalle evidenze** (non da un numero); si aggiorna in modo esplicito dal link "stato". Sono due piani distinti: *punteggio di rischio* ≠ *stato di conformità*. Un requisito può essere conforme e avere comunque rischio residuo alto (o viceversa). Non dire mai "sei conforme perché il rischio è basso".
- Rischio **inerente** = rischio di non conformità **prima** del trattamento; **residuo** = **dopo** il trattamento (framing coerente con "rischio di non conformità"). "Inerente/residuo" è impostazione **ISO 27005 / ISO 27001 §6.1.3** (accettazione del rischio residuo dal risk owner): **best practice**, non obbligo NIS2.
- Le **soglie** dell'Alert e il metodo P×I sono una **scelta metodologica** dell'organizzazione (rif. ISO/IEC 27001 §6.1.2), **strumento di rappresentazione, NON obbligo**: l'**art. 24 del D.Lgs. 138/2024** richiede un'analisi dei rischi proporzionata e con approccio **multirischio (all-hazard)**, senza imporre una metodologia specifica.
- **Impatto** è una scala di severità 1-5 (non euro).
- **Matrici 5×5**: ce ne sono **due** — una per i rischi dai requisiti (sotto la prima tabella) e una per i rischi trasversali (in fondo). Le celle hanno **4 fasce** (prodotto P×I): 1-2 basso (verde), 3-8 medio (arancione), 9-12 alto (rosso tenue), 15-25 critico (rosso) — stesse soglie dell'Alert. Un **tasto Inerente/Residuo** mostra i giudizi prima o dopo il trattamento. La matrice dei requisiti esclude i "non applicabili". È uno strumento di rappresentazione, non un obbligo (metodo interno, ISO 27001 §6.1.2).
- Dall'Alert si possono creare **azioni** sul rischio (seme del futuro Modulo Azioni: registro azioni, audit, KPI, budget).
## Rischi trasversali (all-hazards) — sezione comprimibile
Oltre ai rischi derivati dai requisiti, i profili **amministrativi** possono registrare **rischi trasversali** non riconducibili a un singolo requisito (ambiente fisico, catena di fornitura, eventi naturali, errore umano). Copre l'approccio **multirischio/all-hazards** richiesto dall'art. 24 D.Lgs. 138/2024 (recepisce l'art. 21 §2 Direttiva UE 2022/2555). Si distingue dai «rischi di non conformità» derivati dai requisiti. La sezione è **comprimibile** e riservata agli amministrativi; resta sempre presente e, se vuota, si apre da sola. NON è stata cancellata (ticket #501 p3): rimuoverla lascerebbe scoperto un obbligo.
## Regole per l'assistente
- Indirizza alla pagina dal **menu in alto "Rischi" → "Mappa dei rischi"**.
- Non presentare "92" (numero requisiti importanti) né i codici RSK-… come dati/codici ufficiali ACN.
- Cita, quando serve: Determinazione ACN n. 164179/2025 (All.1 importanti, All.2 essenziali) e art. 24 D.Lgs. 138/2024.
+57
View File
@@ -0,0 +1,57 @@
FONTE: Documentazione interfaccia prodotto NIS2 Agile (interfaccia "V3" — menu in alto, tema navy/oro, font Inter, icone Lucide).
AUTORITA: Team prodotto NIS2 Agile — descrizione UI/navigazione (NON contenuto normativo).
SCOPO: guida operativa per l'assistente AI ARIA, così da indirizzare l'utente nell'interfaccia dell'app. Descrive SOLO struttura e navigazione; nessuna affermazione normativa.
> ⚠️ AGGIORNATO 2026-06-27 — questo documento SOSTITUISCE le versioni precedenti che descrivevano la **sidebar a sinistra** (interfaccia "V2" Bootstrap Italia). Dal 27/6 l'interfaccia è la **V3**: il menu è una **barra IN ALTO** (top-nav a mega-menu), NON più una barra laterale. Gli URL delle pagine restano normali (`dashboard.html`, `risks.html`, …).
# NIS2 Agile — Interfaccia V3: struttura e navigazione
## Com'è fatta l'interfaccia V3
L'app è **multi-pagina** (non SPA): ogni modulo è una pagina a sé. La navigazione avviene da una **barra in alto** (chrome) presente in tutte le pagine applicative; **non esiste più la barra laterale a sinistra**. Tema **navy + oro**, font **Inter**, icone **Lucide**.
La barra in alto ha due righe:
1. **Riga superiore**: logo "N2" e nome "NIS2 Agile" a sinistra; un **campo di ricerca** al centro; a destra il **selettore lingua IT/EN**, il pulsante **rosso "Registra incidente"**, e il **menu utente** (avatar + nome → apre Impostazioni, versione/changelog, Esci). Per i consulenti multi-organizzazione, a destra compare anche un **selettore azienda** (org-switcher).
2. **Riga di navigazione**: voci con **menu a tendina (mega-menu)**. La voce/gruppo della pagina corrente è evidenziata (bordo oro, `aria-current`).
In basso a destra restano i **3 pulsanti flottanti (FAB)**: assistente **ARIA** (chat), **segnalazione** bug/supporto, **notifiche** (campana con badge contatore).
## Voci del menu in alto (mega-menu)
Voci dirette (senza tendina):
- **Dashboard** → panoramica conformità.
- **Calendario** → scadenze e adempimenti in vista mese/lista.
Gruppi a tendina:
- **Stakeholders**: *Stakeholders* (soggetti interni/esterni) · *Supply-Chain* (rischio fornitori) · *Attività su Stakeholders* (interazioni e impegni).
- **Struttura interna**: *Organigramma* (ruoli e deleghe) · *Competenze* (skill matrix) · *GAP Analysis* (assessment di conformità) · *Matrice RACI* (responsabilità R/A/C/I) · *Formazione* (piani formativi).
- **Rischi**: *Mappa dei rischi* (registro rischi e trattamenti) · *Inventario* (asset e perimetro tecnico) · *Policy* (politiche) · *Connettori Discovery* (scansione rete/cloud che auto-popola l'Inventario).
- **Monitoraggio**: *Segnalazioni ricevute* (whistleblowing) · *Audit interni* · *Audit ACN* (verifiche/report dell'autorità) · *Controlli periodici* · *Riesame di Direzione* (§9.3) · *Scadenziario* (revisioni ricorrenti).
- **Lex e ISO**: *Normative* (Direttiva e decreto) · *Modello SGSI (ISO 27001)* · *Misure e requisiti ACN* (misure tecniche/organizzative).
- **AI**: *AI Cross-Analysis* (analisi trasversale) · *Knowledge Base* (procedure e linee guida).
- **Guida**: *Guida all'uso* · *Compliance Journey* (percorso guidato) · *Integrazioni Esterne* · *Impostazioni* · *Architettura* · *Simulazione Demo*.
## Dove trovo… (mappa funzioni → menu)
- **Registrare un incidente** → pulsante **rosso "Registra incidente"** in alto a destra (porta alla pagina Incidenti).
- **Inventario degli asset** → menu **Rischi → Inventario**.
- **Valutare la rilevanza NIS2 di un asset** → menu Rischi → Inventario, pulsante "Valuta Rilevanza NIS2" sulla riga dell'asset.
- **Valutazione del rischio / matrice** → menu **Rischi → Mappa dei rischi**.
- **Misure e requisiti ACN** → menu **Lex e ISO → Misure e requisiti ACN**.
- **Gap analysis / assessment** → menu **Struttura interna → GAP Analysis**.
- **Modello SGSI / SoA / 111 controlli ISO 27001** → menu **Lex e ISO → Modello SGSI (ISO 27001)**.
- **Fornitori / supply chain** → menu **Stakeholders → Supply-Chain**.
- **Segnalazioni whistleblowing** → menu **Monitoraggio → Segnalazioni ricevute**.
- **Impostazioni, membri, API key, branding** → menu utente (in alto a destra) → **Impostazioni**, oppure menu **Guida → Impostazioni**.
- **Cambiare lingua** → selettore **IT/EN** in alto a destra.
- **Aiuto contestuale** → pulsante **"?"** presente nelle pagine (apre la guida della pagina); chat **ARIA** dal FAB in basso a destra.
## Pagine pubbliche (senza menu in alto)
Login, registrazione, onboarding, recupero password, landing e la pagina di **segnalazione anonima** NON hanno la barra di navigazione: sono pagine pubbliche. Le pagine standalone `simulate`/`simulate-b2b` (demo) e i portali esterni (questionario fornitore, portale stakeholder) non usano la chrome dell'app.
## Regole per l'assistente
- Indica sempre **il menu in alto** e il gruppo (es. "menu **Rischi → Inventario**"). **Non** dire "barra laterale" o "sidebar": non esiste più.
- Usa **solo** i nomi delle voci elencate qui sopra; non inventare moduli, "fasi" o codici.
- Non citare nomi di file o URL interni: l'utente naviga dalle voci di menu.
+361
View File
@@ -0,0 +1,361 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GAP Analysis · NIS2 Agile</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{
--navy-top:#0A2E54; --navy-nav:#103A61; --navy-hover:#1B4A75; --gold:#F0B429;
--bg:#EFF4FA; --card:#FFFFFF; --ink:#15263C; --muted:#64748B; --line:#E3E9F0;
--c-green:#1E8A5A; --c-blue:#1F5FA8; --c-amber:#D98324; --c-red:#C62828;
--radius:12px; --shadow:0 1px 2px rgba(16,40,80,.06),0 8px 24px rgba(16,40,80,.06);
}
*{box-sizing:border-box} html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:'Inter',system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:14px;line-height:1.5}
a{color:inherit;text-decoration:none}
:focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:4px}
.appbar{position:sticky;top:0;z-index:50}
.bar-top{background:var(--navy-top);color:#fff}
.bar-top-in{max-width:1280px;margin:0 auto;display:flex;align-items:center;gap:16px;padding:10px 24px}
.flag{width:18px;height:13px;border-radius:2px;overflow:hidden;display:flex;flex:0 0 auto;box-shadow:0 0 0 1px rgba(255,255,255,.25)}
.flag i{flex:1}.flag .g{background:#1f8a4c}.flag .w{background:#fff}.flag .r{background:#cd2b2b}
.logo-mark{width:34px;height:34px;border-radius:8px;background:var(--c-green);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:800;font-size:13px;flex:0 0 auto}
.brand-txt{line-height:1.1;min-width:0}
.brand-txt b{font-size:15px;font-weight:700}
.brand-txt span{display:block;font-size:10.5px;color:#A9C2DC;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.search{flex:1;max-width:340px;display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.16);border-radius:8px;padding:7px 12px;color:#cfe0f1;min-width:120px}
.search input{flex:1;border:0;background:transparent;color:#fff;font:inherit;outline:none;min-width:0}
.search input::placeholder{color:#9db8d2}
.spacer{flex:1}
.btn-incident{display:inline-flex;align-items:center;gap:8px;background:#D24545;color:#fff;border:0;border-radius:8px;padding:9px 15px;font:inherit;font-weight:600;font-size:13px;cursor:pointer;box-shadow:0 1px 2px rgba(120,20,20,.35);white-space:nowrap}
.btn-incident:hover{background:#BF3A3A}
.btn-incident .ic{width:16px;height:16px}
.user{display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.16);border-radius:8px;padding:5px 10px 5px 5px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
.user:hover{background:rgba(255,255,255,.16)}
.avatar{width:24px;height:24px;border-radius:6px;background:var(--c-green);color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700}
.bar-nav{background:var(--navy-nav);border-top:1px solid rgba(255,255,255,.06)}
.bar-nav-in{max-width:1280px;margin:0 auto;padding:0 16px;display:flex;align-items:stretch;gap:2px;flex-wrap:wrap}
.nav-item{position:relative}
.nav-link{display:flex;align-items:center;gap:7px;color:#cfe0f1;font-weight:600;font-size:13px;padding:13px 14px;white-space:nowrap;border-bottom:3px solid transparent;cursor:pointer}
.nav-link .ic{width:15px;height:15px;opacity:.9}
.nav-link .caret{width:13px;height:13px;opacity:.7;margin-left:-2px}
.nav-item:hover>.nav-link{color:#fff;background:rgba(255,255,255,.06)}
.nav-link.active{color:#fff;border-bottom-color:var(--gold)}
.mega{position:absolute;top:100%;left:0;background:#fff;color:var(--ink);border-radius:0 0 14px 14px;box-shadow:0 18px 40px rgba(11,40,80,.18);border:1px solid var(--line);border-top:0;padding:22px;display:none;grid-template-columns:auto auto 250px;gap:30px;min-width:560px;z-index:60}
.nav-item:hover .mega,.nav-item:focus-within .mega{display:grid}
.nav-item:nth-last-child(-n+3) .mega{left:auto;right:0}
.mega.no-promo{min-width:auto}
.nav-item:hover .mega.no-promo,.nav-item:focus-within .mega.no-promo{display:flex;gap:30px;width:auto}
.mega-col{min-width:190px}
.mega-head{font-size:10.5px;font-weight:700;letter-spacing:.12em;color:#94A3B8;text-transform:uppercase;margin:0 0 12px}
.mega-link{display:flex;gap:11px;padding:8px;border-radius:9px;align-items:flex-start}
.mega-link:hover{background:#F3F7FC}
.mega-link .mega-ic{width:36px;height:36px;border-radius:10px;background:#EAF1FA;color:var(--c-blue);display:flex;align-items:center;justify-content:center;flex:0 0 auto}
.mega-ic .ic{width:21px;height:21px;display:block}
.mega-link b{display:block;font-size:13.5px;font-weight:700}
.mega-link span{display:block;font-size:11.5px;color:var(--muted);margin-top:2px}
.promo{background:linear-gradient(160deg,#1F5FA8,#0F3F77);color:#fff;border-radius:12px;padding:18px;display:flex;flex-direction:column;gap:10px}
.promo .badge{align-self:flex-start;background:rgba(255,255,255,.18);font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;padding:4px 9px;border-radius:999px}
.promo b{font-size:16px}
.promo p{margin:0;font-size:12px;color:#cfe0f3;line-height:1.45}
.promo .go{margin-top:auto;align-self:flex-end;width:38px;height:38px;border-radius:9px;background:#fff;color:var(--c-blue);display:flex;align-items:center;justify-content:center}
.wrap{max-width:1280px;margin:0 auto;padding:22px 24px 80px}
.crumb{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);margin-bottom:14px}
.crumb .ic{width:13px;height:13px}.crumb a:hover{color:var(--ink)}
.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:24px;flex-wrap:wrap}
.page-head h1{margin:0;font-size:26px;font-weight:800;letter-spacing:-.4px}
.page-head .sub{margin:4px 0 0;color:var(--muted);font-size:13.5px}
.date{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;background:#fff;border:1px solid var(--line);border-radius:9px;padding:8px 12px}
.date .ic{width:15px;height:15px}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow)}
.card-pad{padding:20px}
.stack{display:grid;gap:18px}
.cal-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;flex-wrap:wrap}
.cal-head h3{margin:0;font-size:15px;font-weight:700}
.cal-ctrl{display:flex;gap:6px;align-items:center}
.ctrl-btn{width:32px;height:32px;border:1px solid var(--line);border-radius:8px;background:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer}
.ctrl-btn:hover{background:#F5F8FC}
.ctrl-btn .ic{width:16px;height:16px;color:var(--muted)}
.ctrl-today{height:32px;padding:0 13px;border:1px solid var(--line);border-radius:8px;background:#fff;font:inherit;font-weight:600;font-size:12px;display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--ink)}
.ctrl-today:hover{background:#F5F8FC}
.ctrl-today .ic{width:14px;height:14px;color:var(--c-blue)}
.daystrip{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px;scrollbar-width:thin}
.daycell{flex:1 1 0;min-width:64px;border:1px solid var(--line);border-radius:11px;background:#fff;padding:11px 6px 9px;text-align:center;position:relative;cursor:pointer;transition:border-color .12s,transform .12s,box-shadow .12s}
.daycell:hover{border-color:#9FBEDD;box-shadow:0 4px 12px rgba(16,40,80,.08)}
.daycell .wd{font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#8093A8}
.daycell .dn{font-size:21px;font-weight:800;line-height:1.15;margin-top:3px}
.daycell .mo{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-top:1px}
.daycell.weekend{background:#F5F8FC}
.daycell.weekend .wd{color:#A6B4C6}
.daycell.today{background:var(--navy-nav);border-color:var(--navy-nav)}
.daycell.today:hover{border-color:var(--navy-hover);box-shadow:0 6px 16px rgba(16,40,80,.22)}
.daycell.today .wd{color:#C7D9EC}.daycell.today .dn,.daycell.today .mo{color:#fff}
.daycell.today::before{content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--gold);border-radius:11px 11px 0 0}
.dots{display:flex;gap:3px;justify-content:center;margin-top:7px;min-height:6px}
.ev{width:6px;height:6px;border-radius:50%;display:inline-block}
.ev.green{background:var(--c-green)}.ev.blue{background:var(--c-blue)}.ev.amber{background:var(--c-amber)}.ev.red{background:var(--c-red)}
.weekstrip{display:grid;grid-template-columns:1fr 1.25fr 1fr;gap:14px;align-items:start}
.weekblock{border:1px solid var(--line);border-radius:12px;background:#FAFCFE;padding:13px;cursor:pointer;transition:border-color .12s,box-shadow .12s}
.weekblock:hover{border-color:#9FBEDD;box-shadow:0 6px 16px rgba(16,40,80,.10)}
.weekblock.current{background:#fff;border-color:#B9CFE6;box-shadow:0 6px 18px rgba(16,40,80,.08)}
.weekblock .wl{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:8px}
.weekblock .wl b{font-size:12.5px;font-weight:700;color:var(--muted)}
.weekblock.current .wl b{color:var(--c-blue)}
.weekblock .wl span{font-size:9.5px;color:#A6B4C6;text-transform:uppercase;letter-spacing:.07em;font-weight:700;white-space:nowrap}
.week7{display:grid;grid-template-columns:repeat(7,1fr);gap:3px}
.mini{text-align:center;border-radius:8px;padding:6px 0 5px}
.mini .mi-wd{font-size:9.5px;font-weight:700;color:#9aa8ba;text-transform:uppercase}
.mini .mi-dn{font-size:13px;font-weight:700;margin-top:1px}
.mini.weekend .mi-wd,.mini.weekend .mi-dn{color:#A6B4C6}
.mini.today{background:var(--navy-nav)}.mini.today .mi-wd{color:#C7D9EC}.mini.today .mi-dn{color:#fff}
.mini .mi-ev{display:flex;gap:2px;justify-content:center;min-height:5px;margin-top:3px}
.mini .mi-ev .ev{width:4px;height:4px}
.cal-pop{position:fixed;z-index:80;display:none;width:252px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 14px 36px rgba(11,40,80,.20);padding:13px 14px;pointer-events:none;opacity:0;transform:translateY(5px);transition:opacity .12s ease,transform .12s ease}
.cal-pop.show{opacity:1;transform:none}
.cal-pop h4{margin:0 0 9px;font-size:12.5px;font-weight:700;text-transform:capitalize}
.cal-pop .pe{display:flex;gap:9px;align-items:flex-start;padding:6px 0;border-top:1px solid #EFF3F8}
.cal-pop .pe:first-of-type{border-top:0;padding-top:0}
.cal-pop .pe .ev{margin-top:5px;flex:0 0 auto}
.cal-pop .pe .tt{font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums;display:block}
.cal-pop .pe b{font-size:12px;font-weight:600;line-height:1.3;display:block}
.cal-pop .empty{font-size:12px;color:#94A3B8}
.fab-stack{position:fixed;right:22px;bottom:22px;display:flex;flex-direction:column;gap:12px;z-index:40}
.fab{width:46px;height:46px;border-radius:50%;border:0;color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 8px 20px rgba(16,40,80,.22)}
.fab .ic{width:20px;height:20px}
.fab.blue{background:var(--c-blue)}.fab.red{background:var(--c-red)}.fab.violet{background:#7C4DD6}
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.split2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
.split-anag{display:grid;grid-template-columns:1.35fr 1fr;gap:18px}
.sec-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
.sec-head h3{margin:0;font-size:15px;font-weight:700}
.sec-head .hint{font-size:11.5px;color:var(--muted)}
.stat{display:flex;gap:14px;align-items:flex-start;border-left:4px solid var(--c-blue)}
.stat.green{border-left-color:var(--c-green)}.stat.amber{border-left-color:var(--c-amber)}.stat.blue{border-left-color:var(--c-blue)}
.stat .ibox{width:42px;height:42px;border-radius:11px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;background:#EAF1FA;color:var(--c-blue)}
.stat.green .ibox{background:#E6F4EC;color:var(--c-green)}.stat.amber .ibox{background:#FBF1E2;color:var(--c-amber)}
.stat .ibox .ic{width:20px;height:20px}
.stat .lbl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#7a8aa0}
.stat .val{font-size:27px;font-weight:800;line-height:1.1;margin:3px 0 1px}
.stat.green .val{color:var(--c-green)}.stat.amber .val{color:var(--c-amber)}.stat.blue .val{color:var(--c-blue)}
.stat .sub{font-size:12px;color:var(--muted)}
.bar{height:7px;background:#EAF0F6;border-radius:99px;overflow:hidden;margin-top:9px;max-width:180px}
.bar>i{display:block;height:100%;background:var(--c-amber);border-radius:99px}
.btn-primary{display:inline-flex;align-items:center;gap:7px;background:var(--c-blue);color:#fff;border:0;border-radius:8px;padding:8px 13px;font:inherit;font-weight:600;font-size:12.5px;cursor:pointer}
.btn-primary:hover{background:#1A5494}
.btn-primary .ic{width:15px;height:15px}
.btn-ghost{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:7px 12px;font:inherit;font-weight:600;font-size:12.5px;color:var(--ink);cursor:pointer}
.btn-ghost:hover{background:#F5F8FC;border-color:#c7d4e4}
.btn-ghost .ic{width:15px;height:15px;color:var(--c-blue)}
.dpill{flex:0 0 auto;width:46px;text-align:center;background:#EFF4FB;border:1px solid var(--line);border-radius:9px;padding:6px 0}
.dpill b{display:block;font-size:15px;font-weight:800;line-height:1}
.dpill span{font-size:9.5px;font-weight:700;letter-spacing:.05em;color:var(--muted);text-transform:uppercase}
.sca{display:flex;gap:13px;align-items:flex-start;padding:12px 0;border-top:1px solid var(--line)}
.sca:first-of-type{border-top:0;padding-top:0}
.sca .body{flex:1;min-width:0}
.sca .body b{font-size:13px;font-weight:600;display:block}
.meta{display:flex;gap:8px;align-items:center;margin-top:5px;flex-wrap:wrap}
.who{font-size:11.5px;color:var(--muted)}
.badge-t{font-size:10px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;padding:2px 7px;border-radius:6px;background:#EEF2F7;color:#5b6b80;white-space:nowrap}
.badge-t.blue{background:#E7F0FB;color:#1F5FA8}.badge-t.amber{background:#FBF1E2;color:#9A6418}.badge-t.red{background:#FBE9E9;color:#B23636}.badge-t.green{background:#E6F4EC;color:#1E7A4E}.badge-t.gray{background:#EEF2F7;color:#5b6b80}
.act-scroll{max-height:330px;overflow-y:auto;padding-right:4px;scrollbar-width:thin}
.act{display:flex;gap:12px;align-items:flex-start;justify-content:space-between;padding:12px;border:1px solid var(--line);border-radius:10px;margin-bottom:8px;background:#fff}
.act:last-child{margin-bottom:0}
.act .body{flex:1;min-width:0}.act .body b{font-size:13px;font-weight:600;display:block}
.anag{display:flex;gap:16px;align-items:flex-start}
.anag-logo{width:60px;height:60px;border-radius:13px;background:linear-gradient(135deg,#143A5C,#1F5FA8);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:17px;letter-spacing:.5px;flex:0 0 auto}
.anag .info{flex:1;min-width:0}
.anag .nm{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.anag .nm h3{margin:0;font-size:16px;font-weight:800}
.badge-nis{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:6px;background:#E7F0FB;color:#1F5FA8}
.anag-grid{display:grid;grid-template-columns:1fr 1fr;gap:11px 18px;margin-top:14px}
.anag-grid .f span{display:block;font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#8093A8}
.anag-grid .f b{font-size:13px;font-weight:600}
.doc{display:flex;align-items:center;gap:12px;width:100%;text-align:left;border:1px solid var(--line);background:#fff;border-radius:10px;padding:12px 14px;cursor:pointer;font:inherit;margin-bottom:10px}
.doc:last-child{margin-bottom:0}
.doc:hover{border-color:#c7d4e4;background:#FAFCFE}
.doc .di{width:36px;height:36px;border-radius:9px;background:#EAF1FA;color:var(--c-blue);display:flex;align-items:center;justify-content:center;flex:0 0 auto}
.doc .di .ic{width:18px;height:18px}
.doc .dt{flex:1;min-width:0}
.doc .dt b{font-size:13.5px;font-weight:700;display:block}
.doc .dt small{font-size:11px;color:var(--muted)}
.doc .st{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-weight:700;padding:5px 10px;border-radius:999px;white-space:nowrap}
.doc .st .ic{width:14px;height:14px}
.doc.has .st{background:#E6F4EC;color:#1E7A4E}
.doc.miss .st{background:#FBF1E2;color:#9A6418}
@media (max-width:900px){.weekstrip{grid-template-columns:1fr}.grid3,.split2,.split-anag{grid-template-columns:1fr}.mega{grid-template-columns:1fr;min-width:280px}.search{display:none}}
@media (max-width:560px){.brand-txt span{display:none}.btn-incident span{display:none}}
.ga-intro{display:flex;gap:12px;align-items:flex-start;background:#EEF4FB;border:1px solid #D5E3F2;color:#2C4A6B;border-radius:11px;padding:14px 16px;font-size:13px;margin-bottom:20px}
.ga-intro .ic{width:18px;height:18px;flex:0 0 auto;color:var(--c-blue);margin-top:1px}
.ga-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:20px}
.ga-grid{display:grid;grid-template-columns:330px 1fr;gap:18px;align-items:start}
.ga-side .sec-head{margin-bottom:12px}
.emp-list{display:flex;flex-direction:column;gap:10px}
.emp{display:flex;align-items:center;gap:12px;padding:12px 13px;border:1px solid var(--line);border-radius:11px;background:#fff;cursor:pointer;box-shadow:var(--shadow);text-align:left;font:inherit;width:100%}
.emp:hover{border-color:#9FBEDD}
.emp.on{border-color:var(--c-blue);box-shadow:0 0 0 2px rgba(31,95,168,.18)}
.emp .av{width:40px;height:40px;border-radius:10px;background:linear-gradient(135deg,#143A5C,#1F5FA8);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;flex:0 0 auto}
.emp .ei{flex:1;min-width:0}
.emp .ei b{font-size:13.5px;font-weight:700;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.emp .ei span{font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
.emp .mt{font-size:14px;font-weight:800;flex:0 0 auto}
.emp .mt.ok{color:var(--c-green)}.emp .mt.warn{color:var(--c-amber)}.emp .mt.crit{color:var(--c-red)}
.det{background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);overflow:hidden}
.det-head{display:flex;align-items:center;gap:16px;padding:20px;border-bottom:1px solid var(--line)}
.det-head .av{width:54px;height:54px;border-radius:12px;background:linear-gradient(135deg,#143A5C,#1F5FA8);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:17px;flex:0 0 auto}
.det-head .hi{flex:1;min-width:0}
.det-head .hi h3{margin:0;font-size:18px;font-weight:800}
.det-head .hi .role{font-size:13px;color:var(--muted);margin-top:2px}
.det-head .score{text-align:right;flex:0 0 auto}
.det-head .score .pct{font-size:26px;font-weight:800;line-height:1}
.det-head .score .pct.ok{color:var(--c-green)}.det-head .score .pct.warn{color:var(--c-amber)}.det-head .score .pct.crit{color:var(--c-red)}
.det-head .score .lb{font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#8093A8;margin-top:3px}
.det-body{padding:6px 20px 14px}
.crow{display:grid;grid-template-columns:1.25fr 250px 116px;gap:18px;align-items:center;padding:14px 0;border-top:1px solid var(--line)}
.crow:first-child{border-top:0}
.crow .cn{font-size:13.5px;font-weight:600}
.levels{display:grid;gap:7px}
.lvlrow{display:flex;align-items:center;gap:9px}
.lvlrow .lab{font-size:9.5px;font-weight:700;color:#8093A8;text-transform:uppercase;width:70px;letter-spacing:.03em}
.dots5{display:flex;gap:5px}
.dots5 i{width:13px;height:13px;border-radius:50%;background:#E7ECF2;display:block}
.dots5 i.req{background:#94A3B8}
.gbadge{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;padding:4px 0;border-radius:999px;text-align:center}
.gbadge.ok{background:#E6F4EC;color:#1E7A4E}
.gbadge.warn{background:#FBF1E2;color:#9A6418}
.gbadge.crit{background:#FBE9E9;color:#B23636}
@media (max-width:900px){.ga-kpis{grid-template-columns:repeat(2,1fr)}.ga-grid{grid-template-columns:1fr}.crow{grid-template-columns:1fr;gap:10px}}
</style>
</head>
<body>
<header class="appbar">
<div class="bar-top"><div class="bar-top-in">
<span class="flag"><i class="g"></i><i class="w"></i><i class="r"></i></span>
<div class="logo-mark">N2</div>
<div class="brand-txt"><b>NIS2 Agile</b><span>Conformità Direttiva (UE) 2022/2555 · D.Lgs. 138/2024</span></div>
<label class="search"><i data-lucide="search" class="ic"></i><input placeholder="Cerca misura, rischio, incidente…"></label>
<span class="spacer"></span>
<button class="btn-incident"><i data-lucide="siren" class="ic"></i><span>Registra incidente</span></button>
<span class="user"><span class="avatar">SF</span>Simone Fatteri<i data-lucide="chevron-down" class="ic"></i></span>
</div></div>
<nav class="bar-nav"><div class="bar-nav-in">
<div class="nav-item"><a class="nav-link" href="index.html"><i data-lucide="layout-dashboard" class="ic"></i><span>Dashboard</span></a></div>
<div class="nav-item"><a class="nav-link" href="calendario.html"><i data-lucide="calendar" class="ic"></i><span>Calendario</span></a></div>
<div class="nav-item"><a class="nav-link" href="stakeholders.html"><i data-lucide="users" class="ic"></i><span>Stakeholders</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">RELAZIONI</p><a class="mega-link" href="stakeholders.html"><span class="mega-ic"><i data-lucide="handshake" class="ic"></i></span><span><b>Stakeholders</b><span>Soggetti interni ed esterni rilevanti</span></span></a><a class="mega-link" href="supply-chain.html"><span class="mega-ic"><i data-lucide="link" class="ic"></i></span><span><b>Supply-Chain</b><span>Rischio fornitori e terze parti</span></span></a><a class="mega-link" href="stakeholder-activities.html"><span class="mega-ic"><i data-lucide="activity" class="ic"></i></span><span><b>Attività su Stakeholders</b><span>Interazioni, richieste e impegni</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link active" href="organigramma.html"><i data-lucide="building-2" class="ic"></i><span>Struttura interna</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">RUOLI E COMPETENZE</p><a class="mega-link" href="organigramma.html"><span class="mega-ic"><i data-lucide="network" class="ic"></i></span><span><b>Organigramma</b><span>Ruoli, deleghe e responsabilità</span></span></a><a class="mega-link" href="competenze.html"><span class="mega-ic"><i data-lucide="award" class="ic"></i></span><span><b>Competenze</b><span>Skill matrix e copertura dei ruoli</span></span></a><a class="mega-link" href="assessment.html"><span class="mega-ic"><i data-lucide="git-compare" class="ic"></i></span><span><b>GAP Analysis</b><span>Scostamento tra stato attuale e atteso</span></span></a><a class="mega-link" href="training.html"><span class="mega-ic"><i data-lucide="graduation-cap" class="ic"></i></span><span><b>Formazione</b><span>Piani formativi e completamento</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="risks.html"><i data-lucide="shield-alert" class="ic"></i><span>Rischi</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">GESTIONE RISCHIO</p><a class="mega-link" href="risks.html"><span class="mega-ic"><i data-lucide="map" class="ic"></i></span><span><b>Mappa dei rischi</b><span>Registro rischi e piani di trattamento</span></span></a><a class="mega-link" href="assets.html"><span class="mega-ic"><i data-lucide="boxes" class="ic"></i></span><span><b>Inventario</b><span>Asset e perimetro tecnico</span></span></a><a class="mega-link" href="policies.html"><span class="mega-ic"><i data-lucide="file-text" class="ic"></i></span><span><b>Policy</b><span>Politiche approvate e in revisione</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="whistleblowing.html"><i data-lucide="radar" class="ic"></i><span>Monitoraggio</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">VIGILANZA E AUDIT</p><a class="mega-link" href="whistleblowing.html"><span class="mega-ic"><i data-lucide="megaphone" class="ic"></i></span><span><b>Segnalazioni ricevute</b><span>Whistleblowing e segnalazioni interne</span></span></a><a class="mega-link" href="internal-audits.html"><span class="mega-ic"><i data-lucide="clipboard-check" class="ic"></i></span><span><b>Audit interni</b><span>Programma e esiti degli audit</span></span></a><a class="mega-link" href="reports.html"><span class="mega-ic"><i data-lucide="file-check" class="ic"></i></span><span><b>Audit ACN</b><span>Verifiche e ispezioni dell'autorità</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="report.html"><i data-lucide="scale" class="ic"></i><span>Lex e ISO</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">NORMATIVA E ISO</p><a class="mega-link" href="normative.html"><span class="mega-ic"><i data-lucide="scroll-text" class="ic"></i></span><span><b>Normative</b><span>Direttiva, decreto e riferimenti applicabili</span></span></a><a class="mega-link" href="isms.html"><span class="mega-ic"><i data-lucide="shield-check" class="ic"></i></span><span><b>Modello SGSI (ISO 27001)</b><span>Sistema di gestione sicurezza informazioni</span></span></a><a class="mega-link" href="misure-requisiti.html"><span class="mega-ic"><i data-lucide="list-checks" class="ic"></i></span><span><b>Misure e requisiti ACN</b><span>Misure tecniche e organizzative Art. 21</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="ai.html"><i data-lucide="sparkles" class="ic"></i><span>AI</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">ASSISTENTE</p><a class="mega-link" href="cross-analysis.html"><span class="mega-ic"><i data-lucide="sparkles" class="ic"></i></span><span><b>AI Cross-Analysis</b><span>Analisi trasversale di rischi e misure</span></span></a><a class="mega-link" href="kb.html"><span class="mega-ic"><i data-lucide="book-open" class="ic"></i></span><span><b>Knowledge Base</b><span>Procedure, linee guida e riferimenti</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="guida.html"><i data-lucide="life-buoy" class="ic"></i><span>Guida</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">GUIDA</p><a class="mega-link" href="guida.html"><span class="mega-ic"><i data-lucide="life-buoy" class="ic"></i></span><span><b>Guida all'uso</b><span>Manuale e istruzioni della piattaforma</span></span></a><a class="mega-link" href="integrazioniext.html"><span class="mega-ic"><i data-lucide="plug" class="ic"></i></span><span><b>Integrazioni Esterne</b><span>Connettori e servizi collegati</span></span></a></div><div class="mega-col"><p class="mega-head">SISTEMA</p><a class="mega-link" href="setting.html"><span class="mega-ic"><i data-lucide="settings" class="ic"></i></span><span><b>Impostazioni</b><span>Configurazione del workspace</span></span></a><a class="mega-link" href="architecture.html"><span class="mega-ic"><i data-lucide="layers" class="ic"></i></span><span><b>Architettura</b><span>Modello dati e struttura del sistema</span></span></a></div></div></div>
</div></nav>
</header>
<main class="wrap">
<nav class="crumb"><i data-lucide="home" class="ic"></i><a href="index.html">Home</a><i data-lucide="chevron-right" class="ic"></i><a href="organigramma.html">Struttura interna</a><i data-lucide="chevron-right" class="ic"></i><span>GAP Analysis</span></nav>
<div class="page-head">
<div><h1>GAP Analysis — Competenze</h1><p class="sub">Allineamento tra le competenze possedute dai dipendenti e quelle richieste dal ruolo ricoperto</p></div>
<div class="date"><i data-lucide="calendar" class="ic"></i>giovedì 25 giugno 2026</div>
</div>
<div class="ga-intro"><i data-lucide="info" class="ic"></i><div>Per ogni dipendente dell'organigramma vengono confrontate le competenze <b>richieste dal ruolo</b> con quelle <b>effettivamente possedute</b>, evidenziando gli scostamenti (gap) su cui intervenire con la formazione.</div></div>
<div class="ga-kpis" id="ga-kpis"></div>
<div class="ga-grid">
<aside class="ga-side">
<div class="sec-head"><h3>Dipendenti</h3></div>
<div class="emp-list" id="emp-list"></div>
</aside>
<section class="det" id="detail"></section>
</div>
</main>
<div class="fab-stack">
<button class="fab blue" title="Notifiche"><i data-lucide="bell" class="ic"></i></button>
<button class="fab red" title="Assistenza"><i data-lucide="life-buoy" class="ic"></i></button>
<button class="fab violet" title="Assistente AI"><i data-lucide="sparkles" class="ic"></i></button>
</div>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script>
const EMP=[
{id:1,name:'Marco Bianchi',role:'CISO / Resp. Sicurezza',comps:[
['Gestione del rischio ICT',5,4],['Sicurezza delle reti',5,5],['Gestione incidenti (Art. 23)',5,3],
['Conformit\u00e0 NIS2',4,4],['Crittografia',3,2],['Continuit\u00e0 operativa',4,4]]},
{id:2,name:'Laura Verdi',role:'Responsabile IT',comps:[
['Amministrazione sistemi',5,5],['Gestione accessi (IAM)',4,3],['Backup e ripristino',4,4],
['Hardening',3,2],['Monitoraggio e log',3,3]]},
{id:3,name:'Andrea Rossi',role:'SOC Analyst',comps:[
['Monitoraggio SIEM',4,4],['Analisi incidenti',4,3],['Threat intelligence',3,2],['Digital forensics',3,1]]},
{id:4,name:'Giulia Neri',role:'Data Protection / Compliance',comps:[
['Normativa privacy (GDPR)',4,4],['Conformit\u00e0 NIS2',4,3],['Gestione documentale',3,3],['Audit interni',3,2]]},
{id:5,name:'Paolo Gialli',role:'Resp. Formazione',comps:[
['Pianificazione formazione',4,4],['Security awareness',3,3],['Gestione competenze',3,2]]},
{id:6,name:'Sara Blu',role:'Sistemista',comps:[
['Amministrazione sistemi',3,3],['Gestione accessi',3,2],['Backup e ripristino',3,3],['Hardening',3,3]]}
];
const STC={ok:'var(--c-green)',warn:'var(--c-amber)',crit:'var(--c-red)'};
function ini(n){return n.split(' ').map(p=>p[0]).join('').slice(0,2).toUpperCase();}
function cstat(req,act){const g=req-act;return g<=0?'ok':g===1?'warn':'crit';}
function estat(e){let w=false;for(const[,req,act]of e.comps){const g=req-act;if(g>=2)return'crit';if(g===1)w=true;}return w?'warn':'ok';}
function pct(e){let s=0,t=0;for(const[,req,act]of e.comps){s+=Math.min(act,req);t+=req;}return Math.round(s/t*100);}
function gaps(e){return e.comps.filter(([,req,act])=>req-act>0).length;}
function dots(filled,color){let h='';for(let i=1;i<=5;i++){const on=i<=filled;h+='<i'+(on&&!color?' class="req"':'')+(on&&color?' style="background:'+color+'"':'')+'></i>';}return h;}
function renderKpis(){
const n=EMP.length;
const avg=Math.round(EMP.reduce((a,e)=>a+pct(e),0)/n);
const totGap=EMP.reduce((a,e)=>a+gaps(e),0);
const roles=new Set(EMP.map(e=>e.role)).size;
const cards=[
['blue','users','Dipendenti valutati',n,'nell\'organigramma'],
['green','target','Allineamento medio',avg+'%','competenze vs ruolo'],
['amber','triangle-alert','Competenze in gap',totGap,'da colmare'],
['blue','briefcase','Ruoli coperti',roles,'profili valutati']
];
document.getElementById('ga-kpis').innerHTML=cards.map(([c,ic,l,v,s])=>
'<div class="card card-pad stat '+c+'"><div class="ibox"><i data-lucide="'+ic+'"></i></div>'+
'<div style="flex:1;min-width:0"><div class="lbl">'+l+'</div><div class="val">'+v+'</div><div class="sub">'+s+'</div></div></div>').join('');
}
function renderList(sel){
document.getElementById('emp-list').innerHTML=EMP.map(e=>{
const st=estat(e);
return '<button class="emp'+(e.id===sel?' on':'')+'" data-id="'+e.id+'">'+
'<span class="av">'+ini(e.name)+'</span>'+
'<span class="ei"><b>'+e.name+'</b><span>'+e.role+'</span></span>'+
'<span class="mt '+st+'">'+pct(e)+'%</span></button>';
}).join('');
document.querySelectorAll('.emp').forEach(b=>b.onclick=()=>select(+b.dataset.id));
}
function renderDetail(id){
const e=EMP.find(x=>x.id===id);const st=estat(e);const STL={ok:'In linea',warn:'Da rafforzare',crit:'Gap critico'};
let rows=e.comps.map(([name,req,act])=>{
const s=cstat(req,act);const g=req-act;
const badge=g<=0?'<span class="gbadge ok">In linea</span>':g===1?'<span class="gbadge warn">Gap \u22121</span>':'<span class="gbadge crit">Gap \u2212'+g+'</span>';
return '<div class="crow"><div class="cn">'+name+'</div>'+
'<div class="levels">'+
'<div class="lvlrow"><span class="lab">Richiesto</span><div class="dots5">'+dots(req,null)+'</div></div>'+
'<div class="lvlrow"><span class="lab">Posseduto</span><div class="dots5">'+dots(act,STC[s])+'</div></div>'+
'</div>'+badge+'</div>';
}).join('');
document.getElementById('detail').innerHTML=
'<div class="det-head"><span class="av">'+ini(e.name)+'</span>'+
'<div class="hi"><h3>'+e.name+'</h3><div class="role">'+e.role+'</div></div>'+
'<div class="score"><div class="pct '+st+'">'+pct(e)+'%</div><div class="lb">'+STL[st]+'</div></div></div>'+
'<div class="det-body">'+rows+'</div>';
}
let current=1;
function select(id){current=id;renderList(id);renderDetail(id);lucide.createIcons();}
renderKpis();renderList(current);renderDetail(current);
window.lucide && lucide.createIcons();
</script>
</body></html>
+578
View File
@@ -0,0 +1,578 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calendario · NIS2 Agile</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{
--navy-top:#0A2E54; --navy-nav:#103A61; --navy-hover:#1B4A75; --gold:#F0B429;
--bg:#EFF4FA; --card:#FFFFFF; --ink:#15263C; --muted:#64748B; --line:#E3E9F0;
--c-green:#1E8A5A; --c-blue:#1F5FA8; --c-amber:#D98324; --c-red:#C62828;
--radius:12px; --shadow:0 1px 2px rgba(16,40,80,.06),0 8px 24px rgba(16,40,80,.06);
}
*{box-sizing:border-box} html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:'Inter',system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:14px;line-height:1.5}
a{color:inherit;text-decoration:none}
:focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:4px}
.appbar{position:sticky;top:0;z-index:50}
.bar-top{background:var(--navy-top);color:#fff}
.bar-top-in{max-width:1280px;margin:0 auto;display:flex;align-items:center;gap:16px;padding:10px 24px}
.flag{width:18px;height:13px;border-radius:2px;overflow:hidden;display:flex;flex:0 0 auto;box-shadow:0 0 0 1px rgba(255,255,255,.25)}
.flag i{flex:1}.flag .g{background:#1f8a4c}.flag .w{background:#fff}.flag .r{background:#cd2b2b}
.logo-mark{width:34px;height:34px;border-radius:8px;background:var(--c-green);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:800;font-size:13px;flex:0 0 auto}
.brand-txt{line-height:1.1;min-width:0}
.brand-txt b{font-size:15px;font-weight:700}
.brand-txt span{display:block;font-size:10.5px;color:#A9C2DC;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.search{flex:1;max-width:340px;display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.16);border-radius:8px;padding:7px 12px;color:#cfe0f1;min-width:120px}
.search input{flex:1;border:0;background:transparent;color:#fff;font:inherit;outline:none;min-width:0}
.search input::placeholder{color:#9db8d2}
.spacer{flex:1}
.btn-incident{display:inline-flex;align-items:center;gap:8px;background:#D24545;color:#fff;border:0;border-radius:8px;padding:9px 15px;font:inherit;font-weight:600;font-size:13px;cursor:pointer;box-shadow:0 1px 2px rgba(120,20,20,.35);white-space:nowrap}
.btn-incident:hover{background:#BF3A3A}
.btn-incident .ic{width:16px;height:16px}
.user{display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.16);border-radius:8px;padding:5px 10px 5px 5px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
.user:hover{background:rgba(255,255,255,.16)}
.avatar{width:24px;height:24px;border-radius:6px;background:var(--c-green);color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700}
.bar-nav{background:var(--navy-nav);border-top:1px solid rgba(255,255,255,.06)}
.bar-nav-in{max-width:1280px;margin:0 auto;padding:0 16px;display:flex;align-items:stretch;gap:2px;flex-wrap:wrap}
.nav-item{position:relative}
.nav-link{display:flex;align-items:center;gap:7px;color:#cfe0f1;font-weight:600;font-size:13px;padding:13px 14px;white-space:nowrap;border-bottom:3px solid transparent;cursor:pointer}
.nav-link .ic{width:15px;height:15px;opacity:.9}
.nav-link .caret{width:13px;height:13px;opacity:.7;margin-left:-2px}
.nav-item:hover>.nav-link{color:#fff;background:rgba(255,255,255,.06)}
.nav-link.active{color:#fff;border-bottom-color:var(--gold)}
.mega{position:absolute;top:100%;left:0;background:#fff;color:var(--ink);border-radius:0 0 14px 14px;box-shadow:0 18px 40px rgba(11,40,80,.18);border:1px solid var(--line);border-top:0;padding:22px;display:none;grid-template-columns:auto auto 250px;gap:30px;min-width:560px;z-index:60}
.nav-item:hover .mega,.nav-item:focus-within .mega{display:grid}
.nav-item:nth-last-child(-n+3) .mega{left:auto;right:0}
.mega.no-promo{min-width:auto}
.nav-item:hover .mega.no-promo,.nav-item:focus-within .mega.no-promo{display:flex;gap:30px;width:auto}
.mega-col{min-width:190px}
.mega-head{font-size:10.5px;font-weight:700;letter-spacing:.12em;color:#94A3B8;text-transform:uppercase;margin:0 0 12px}
.mega-link{display:flex;gap:11px;padding:8px;border-radius:9px;align-items:flex-start}
.mega-link:hover{background:#F3F7FC}
.mega-ic{width:30px;height:30px;border-radius:8px;background:#EAF1FA;color:var(--c-blue);display:flex;align-items:center;justify-content:center;flex:0 0 auto}
.mega-ic .ic{width:16px;height:16px}
.mega-link b{display:block;font-size:13.5px;font-weight:700}
.mega-link span{display:block;font-size:11.5px;color:var(--muted);margin-top:2px}
.promo{background:linear-gradient(160deg,#1F5FA8,#0F3F77);color:#fff;border-radius:12px;padding:18px;display:flex;flex-direction:column;gap:10px}
.promo .badge{align-self:flex-start;background:rgba(255,255,255,.18);font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;padding:4px 9px;border-radius:999px}
.promo b{font-size:16px}
.promo p{margin:0;font-size:12px;color:#cfe0f3;line-height:1.45}
.promo .go{margin-top:auto;align-self:flex-end;width:38px;height:38px;border-radius:9px;background:#fff;color:var(--c-blue);display:flex;align-items:center;justify-content:center}
.wrap{max-width:1280px;margin:0 auto;padding:22px 24px 80px}
.crumb{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);margin-bottom:14px}
.crumb .ic{width:13px;height:13px}.crumb a:hover{color:var(--ink)}
.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:24px;flex-wrap:wrap}
.page-head h1{margin:0;font-size:26px;font-weight:800;letter-spacing:-.4px}
.page-head .sub{margin:4px 0 0;color:var(--muted);font-size:13.5px}
.date{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;background:#fff;border:1px solid var(--line);border-radius:9px;padding:8px 12px}
.date .ic{width:15px;height:15px}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow)}
.card-pad{padding:20px}
.stack{display:grid;gap:18px}
.cal-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;flex-wrap:wrap}
.cal-head h3{margin:0;font-size:15px;font-weight:700}
.cal-ctrl{display:flex;gap:6px;align-items:center}
.ctrl-btn{width:32px;height:32px;border:1px solid var(--line);border-radius:8px;background:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer}
.ctrl-btn:hover{background:#F5F8FC}
.ctrl-btn .ic{width:16px;height:16px;color:var(--muted)}
.ctrl-today{height:32px;padding:0 13px;border:1px solid var(--line);border-radius:8px;background:#fff;font:inherit;font-weight:600;font-size:12px;display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--ink)}
.ctrl-today:hover{background:#F5F8FC}
.ctrl-today .ic{width:14px;height:14px;color:var(--c-blue)}
.daystrip{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px;scrollbar-width:thin}
.daycell{flex:1 1 0;min-width:64px;border:1px solid var(--line);border-radius:11px;background:#fff;padding:11px 6px 9px;text-align:center;position:relative;cursor:pointer;transition:border-color .12s,transform .12s,box-shadow .12s}
.daycell:hover{border-color:#9FBEDD;box-shadow:0 4px 12px rgba(16,40,80,.08)}
.daycell .wd{font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#8093A8}
.daycell .dn{font-size:21px;font-weight:800;line-height:1.15;margin-top:3px}
.daycell .mo{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-top:1px}
.daycell.weekend{background:#F5F8FC}
.daycell.weekend .wd{color:#A6B4C6}
.daycell.today{background:var(--navy-nav);border-color:var(--navy-nav)}
.daycell.today:hover{border-color:var(--navy-hover);box-shadow:0 6px 16px rgba(16,40,80,.22)}
.daycell.today .wd{color:#C7D9EC}.daycell.today .dn,.daycell.today .mo{color:#fff}
.daycell.today::before{content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--gold);border-radius:11px 11px 0 0}
.dots{display:flex;gap:3px;justify-content:center;margin-top:7px;min-height:6px}
.ev{width:6px;height:6px;border-radius:50%;display:inline-block}
.ev.green{background:var(--c-green)}.ev.blue{background:var(--c-blue)}.ev.amber{background:var(--c-amber)}.ev.red{background:var(--c-red)}
.weekstrip{display:grid;grid-template-columns:1fr 1.25fr 1fr;gap:14px;align-items:start}
.weekblock{border:1px solid var(--line);border-radius:12px;background:#FAFCFE;padding:13px;cursor:pointer;transition:border-color .12s,box-shadow .12s}
.weekblock:hover{border-color:#9FBEDD;box-shadow:0 6px 16px rgba(16,40,80,.10)}
.weekblock.current{background:#fff;border-color:#B9CFE6;box-shadow:0 6px 18px rgba(16,40,80,.08)}
.weekblock .wl{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:8px}
.weekblock .wl b{font-size:12.5px;font-weight:700;color:var(--muted)}
.weekblock.current .wl b{color:var(--c-blue)}
.weekblock .wl span{font-size:9.5px;color:#A6B4C6;text-transform:uppercase;letter-spacing:.07em;font-weight:700;white-space:nowrap}
.week7{display:grid;grid-template-columns:repeat(7,1fr);gap:3px}
.mini{text-align:center;border-radius:8px;padding:6px 0 5px}
.mini .mi-wd{font-size:9.5px;font-weight:700;color:#9aa8ba;text-transform:uppercase}
.mini .mi-dn{font-size:13px;font-weight:700;margin-top:1px}
.mini.weekend .mi-wd,.mini.weekend .mi-dn{color:#A6B4C6}
.mini.today{background:var(--navy-nav)}.mini.today .mi-wd{color:#C7D9EC}.mini.today .mi-dn{color:#fff}
.mini .mi-ev{display:flex;gap:2px;justify-content:center;min-height:5px;margin-top:3px}
.mini .mi-ev .ev{width:4px;height:4px}
.cal-pop{position:fixed;z-index:80;display:none;width:252px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 14px 36px rgba(11,40,80,.20);padding:13px 14px;pointer-events:none;opacity:0;transform:translateY(5px);transition:opacity .12s ease,transform .12s ease}
.cal-pop.show{opacity:1;transform:none}
.cal-pop h4{margin:0 0 9px;font-size:12.5px;font-weight:700;text-transform:capitalize}
.cal-pop .pe{display:flex;gap:9px;align-items:flex-start;padding:6px 0;border-top:1px solid #EFF3F8}
.cal-pop .pe:first-of-type{border-top:0;padding-top:0}
.cal-pop .pe .ev{margin-top:5px;flex:0 0 auto}
.cal-pop .pe .tt{font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums;display:block}
.cal-pop .pe b{font-size:12px;font-weight:600;line-height:1.3;display:block}
.cal-pop .empty{font-size:12px;color:#94A3B8}
.fab-stack{position:fixed;right:22px;bottom:22px;display:flex;flex-direction:column;gap:12px;z-index:40}
.fab{width:46px;height:46px;border-radius:50%;border:0;color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 8px 20px rgba(16,40,80,.22)}
.fab .ic{width:20px;height:20px}
.fab.blue{background:var(--c-blue)}.fab.red{background:var(--c-red)}.fab.violet{background:#7C4DD6}
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.split2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
.split-anag{display:grid;grid-template-columns:1.35fr 1fr;gap:18px}
.sec-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
.sec-head h3{margin:0;font-size:15px;font-weight:700}
.sec-head .hint{font-size:11.5px;color:var(--muted)}
.stat{display:flex;gap:14px;align-items:flex-start;border-left:4px solid var(--c-blue)}
.stat.green{border-left-color:var(--c-green)}.stat.amber{border-left-color:var(--c-amber)}.stat.blue{border-left-color:var(--c-blue)}
.stat .ibox{width:42px;height:42px;border-radius:11px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;background:#EAF1FA;color:var(--c-blue)}
.stat.green .ibox{background:#E6F4EC;color:var(--c-green)}.stat.amber .ibox{background:#FBF1E2;color:var(--c-amber)}
.stat .ibox .ic{width:20px;height:20px}
.stat .lbl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#7a8aa0}
.stat .val{font-size:27px;font-weight:800;line-height:1.1;margin:3px 0 1px}
.stat.green .val{color:var(--c-green)}.stat.amber .val{color:var(--c-amber)}.stat.blue .val{color:var(--c-blue)}
.stat .sub{font-size:12px;color:var(--muted)}
.bar{height:7px;background:#EAF0F6;border-radius:99px;overflow:hidden;margin-top:9px;max-width:180px}
.bar>i{display:block;height:100%;background:var(--c-amber);border-radius:99px}
.btn-primary{display:inline-flex;align-items:center;gap:7px;background:var(--c-blue);color:#fff;border:0;border-radius:8px;padding:8px 13px;font:inherit;font-weight:600;font-size:12.5px;cursor:pointer}
.btn-primary:hover{background:#1A5494}
.btn-primary .ic{width:15px;height:15px}
.btn-ghost{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:7px 12px;font:inherit;font-weight:600;font-size:12.5px;color:var(--ink);cursor:pointer}
.btn-ghost:hover{background:#F5F8FC;border-color:#c7d4e4}
.btn-ghost .ic{width:15px;height:15px;color:var(--c-blue)}
.dpill{flex:0 0 auto;width:46px;text-align:center;background:#EFF4FB;border:1px solid var(--line);border-radius:9px;padding:6px 0}
.dpill b{display:block;font-size:15px;font-weight:800;line-height:1}
.dpill span{font-size:9.5px;font-weight:700;letter-spacing:.05em;color:var(--muted);text-transform:uppercase}
.sca{display:flex;gap:13px;align-items:flex-start;padding:12px 0;border-top:1px solid var(--line)}
.sca:first-of-type{border-top:0;padding-top:0}
.sca .body{flex:1;min-width:0}
.sca .body b{font-size:13px;font-weight:600;display:block}
.meta{display:flex;gap:8px;align-items:center;margin-top:5px;flex-wrap:wrap}
.who{font-size:11.5px;color:var(--muted)}
.badge-t{font-size:10px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;padding:2px 7px;border-radius:6px;background:#EEF2F7;color:#5b6b80;white-space:nowrap}
.badge-t.blue{background:#E7F0FB;color:#1F5FA8}.badge-t.amber{background:#FBF1E2;color:#9A6418}.badge-t.red{background:#FBE9E9;color:#B23636}.badge-t.green{background:#E6F4EC;color:#1E7A4E}.badge-t.gray{background:#EEF2F7;color:#5b6b80}
.act-scroll{max-height:330px;overflow-y:auto;padding-right:4px;scrollbar-width:thin}
.act{display:flex;gap:12px;align-items:flex-start;justify-content:space-between;padding:12px;border:1px solid var(--line);border-radius:10px;margin-bottom:8px;background:#fff}
.act:last-child{margin-bottom:0}
.act .body{flex:1;min-width:0}.act .body b{font-size:13px;font-weight:600;display:block}
.anag{display:flex;gap:16px;align-items:flex-start}
.anag-logo{width:60px;height:60px;border-radius:13px;background:linear-gradient(135deg,#143A5C,#1F5FA8);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:17px;letter-spacing:.5px;flex:0 0 auto}
.anag .info{flex:1;min-width:0}
.anag .nm{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.anag .nm h3{margin:0;font-size:16px;font-weight:800}
.badge-nis{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:6px;background:#E7F0FB;color:#1F5FA8}
.anag-grid{display:grid;grid-template-columns:1fr 1fr;gap:11px 18px;margin-top:14px}
.anag-grid .f span{display:block;font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#8093A8}
.anag-grid .f b{font-size:13px;font-weight:600}
.doc{display:flex;align-items:center;gap:12px;width:100%;text-align:left;border:1px solid var(--line);background:#fff;border-radius:10px;padding:12px 14px;cursor:pointer;font:inherit;margin-bottom:10px}
.doc:last-child{margin-bottom:0}
.doc:hover{border-color:#c7d4e4;background:#FAFCFE}
.doc .di{width:36px;height:36px;border-radius:9px;background:#EAF1FA;color:var(--c-blue);display:flex;align-items:center;justify-content:center;flex:0 0 auto}
.doc .di .ic{width:18px;height:18px}
.doc .dt{flex:1;min-width:0}
.doc .dt b{font-size:13.5px;font-weight:700;display:block}
.doc .dt small{font-size:11px;color:var(--muted)}
.doc .st{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-weight:700;padding:5px 10px;border-radius:999px;white-space:nowrap}
.doc .st .ic{width:14px;height:14px}
.doc.has .st{background:#E6F4EC;color:#1E7A4E}
.doc.miss .st{background:#FBF1E2;color:#9A6418}
@media (max-width:900px){.weekstrip{grid-template-columns:1fr}.grid3,.split2,.split-anag{grid-template-columns:1fr}.mega{grid-template-columns:1fr;min-width:280px}.search{display:none}}
@media (max-width:560px){.brand-txt span{display:none}.btn-incident span{display:none}}
.cal-app{display:grid;grid-template-columns:250px 1fr;gap:18px;align-items:start}
.cal-side{display:flex;flex-direction:column;gap:16px}
.btn-create{display:inline-flex;align-items:center;justify-content:center;gap:8px;background:var(--c-blue);color:#fff;border:0;border-radius:10px;padding:12px 16px;font:inherit;font-weight:700;font-size:14px;cursor:pointer;box-shadow:0 2px 8px rgba(31,95,168,.3)}
.btn-create:hover{background:#1A5494}
.btn-create .ic{width:18px;height:18px}
.mini{background:#fff;border:1px solid var(--line);border-radius:12px;padding:14px;box-shadow:var(--shadow)}
.mini-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:9px}
.mini-head b{font-size:13px;font-weight:700;text-transform:capitalize}
.mini-nav{display:flex;gap:2px}
.mini-nav button{width:24px;height:24px;border:0;background:transparent;border-radius:6px;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--muted)}
.mini-nav button:hover{background:#F0F4F9}
.mini-nav .ic{width:14px;height:14px}
.mini-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px}
.mini-grid .h{font-size:9.5px;font-weight:700;color:#9aa8ba;text-align:center;padding:3px 0}
.mini-grid .d{font-size:11px;text-align:center;padding:5px 0;border-radius:50%;cursor:pointer;color:var(--ink)}
.mini-grid .d:hover{background:#EDF2F8}
.mini-grid .d.other{color:#C2CCD8}
.mini-grid .d.today{background:var(--c-blue);color:#fff;font-weight:700}
.mini-grid .d.sel{outline:2px solid var(--c-blue);outline-offset:-2px;font-weight:700}
.legend{background:#fff;border:1px solid var(--line);border-radius:12px;padding:14px;box-shadow:var(--shadow)}
.legend p{margin:0 0 9px;font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#8093A8}
.legend .li{display:flex;align-items:center;gap:9px;padding:4px 0;font-size:12.5px}
.legend .li i{width:11px;height:11px;border-radius:3px;flex:0 0 auto}
.cal-main{min-width:0}
.cal-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}
.tb-left{display:flex;align-items:center;gap:10px}
.tb-nav{display:flex;gap:2px}
.tb-title{font-size:19px;font-weight:800;text-transform:capitalize;letter-spacing:-.3px;margin-left:4px}
.view-switch{display:inline-flex;background:#E7EDF4;border-radius:9px;padding:3px}
.view-switch button{border:0;background:transparent;font:inherit;font-weight:600;font-size:12.5px;color:var(--muted);padding:6px 14px;border-radius:7px;cursor:pointer}
.view-switch button.on{background:#fff;color:var(--ink);box-shadow:0 1px 2px rgba(16,40,80,.12)}
.mv{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;box-shadow:var(--shadow)}
.mv-dow{display:grid;grid-template-columns:repeat(7,1fr);background:#F6F9FC;border-bottom:1px solid var(--line)}
.mv-dow div{padding:9px 8px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#7a8aa0}
.mv-grid{display:grid;grid-template-columns:repeat(7,1fr)}
.mc{min-height:108px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);padding:6px 7px;cursor:pointer;position:relative;overflow:hidden}
.mc:nth-child(7n){border-right:0}
.mc:hover{background:#FAFCFE}
.mc .dnum{font-size:12.5px;font-weight:600;width:25px;height:25px;display:flex;align-items:center;justify-content:center;border-radius:50%;color:var(--ink)}
.mc.other{background:#FAFBFD}.mc.other .dnum{color:#AEBAC8}
.mc.today .dnum{background:var(--c-blue);color:#fff}
.mc .chip{display:block;font-size:11px;font-weight:600;color:#fff;border-radius:5px;padding:2px 7px;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
.mc .chip .t{opacity:.9;font-weight:700;margin-right:4px}
.mc .more{font-size:10.5px;color:var(--muted);margin-top:3px;font-weight:600;cursor:pointer}
.tg{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;box-shadow:var(--shadow)}
.tg-head{display:grid;border-bottom:1px solid var(--line)}
.tg-head .corner{border-right:1px solid var(--line)}
.tg-dh{padding:8px 6px;border-right:1px solid var(--line);text-align:center}
.tg-dh:last-child{border-right:0}
.tg-dh .wd{font-size:11px;font-weight:700;text-transform:uppercase;color:#7a8aa0}
.tg-dh .dn{font-size:18px;font-weight:800;line-height:1.25;margin-top:2px;display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:30px}
.tg-dh.today .dn{color:#fff;background:var(--c-blue);border-radius:50%}
.tg-dh .ad{margin-top:5px;display:flex;flex-direction:column;gap:2px}
.tg-dh .ad .chip{font-size:10px;font-weight:600;color:#fff;border-radius:4px;padding:1px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
.tg-scroll{max-height:560px;overflow-y:auto}
.tg-body{display:grid;position:relative}
.tg-hour{border-right:1px solid var(--line);border-top:1px solid var(--line);font-size:10.5px;color:#94A3B8;text-align:right;padding:2px 6px 0 0}
.tg-cell{border-right:1px solid var(--line);border-top:1px solid var(--line);cursor:pointer}
.tg-cell:last-child{border-right:0}
.tg-cell:hover{background:#F7FAFD}
.tg-colev{position:relative;pointer-events:none}
.tg-ev{position:absolute;left:3px;right:3px;border-radius:6px;padding:3px 7px;color:#fff;font-size:11px;font-weight:600;overflow:hidden;cursor:pointer;pointer-events:auto;box-shadow:0 1px 3px rgba(0,0,0,.2);line-height:1.25}
.tg-ev .tm{font-size:9.5px;opacity:.92;font-weight:700;display:block}
.mbk{position:fixed;inset:0;background:rgba(13,28,52,.45);display:none;align-items:center;justify-content:center;z-index:90;padding:20px}
.mbk.show{display:flex}
.modal{background:#fff;border-radius:14px;width:430px;max-width:100%;box-shadow:0 24px 60px rgba(11,30,60,.35);overflow:hidden}
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:15px 18px;border-bottom:1px solid var(--line)}
.modal-head h3{margin:0;font-size:15px;font-weight:700}
.modal-head .x{border:0;background:transparent;cursor:pointer;color:var(--muted);width:30px;height:30px;border-radius:7px;display:flex;align-items:center;justify-content:center}
.modal-head .x:hover{background:#F0F4F9}
.modal-body{padding:18px;display:grid;gap:14px}
.field label{font-size:11.5px;font-weight:700;color:var(--muted);display:block;margin-bottom:5px;text-transform:uppercase;letter-spacing:.03em}
.field input,.field select{width:100%;border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit;font-size:13.5px;outline:none;color:var(--ink);background:#fff}
.field input:focus,.field select:focus{border-color:var(--c-blue);box-shadow:0 0 0 3px rgba(31,95,168,.12)}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.chk{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500;color:var(--ink);cursor:pointer}
.chk input{width:auto}
.cpick{display:flex;gap:9px}
.cpick button{width:28px;height:28px;border-radius:50%;border:2px solid transparent;cursor:pointer}
.cpick button.sel{border-color:var(--ink)}
.modal-foot{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-top:1px solid var(--line);gap:10px}
.btn-del{border:0;background:transparent;color:var(--c-red);font:inherit;font-weight:600;font-size:13px;cursor:pointer;display:inline-flex;align-items:center;gap:6px}
.btn-del .ic{width:15px;height:15px}
.btn-del:hover{text-decoration:underline}
.foot-r{display:flex;gap:10px;margin-left:auto}
@media (max-width:900px){.cal-app{grid-template-columns:1fr}.cal-side{flex-direction:row;flex-wrap:wrap}.mini,.legend{flex:1;min-width:220px}}
</style>
</head>
<body>
<header class="appbar">
<div class="bar-top"><div class="bar-top-in">
<span class="flag"><i class="g"></i><i class="w"></i><i class="r"></i></span>
<div class="logo-mark">N2</div>
<div class="brand-txt"><b>NIS2 Agile</b><span>Conformità Direttiva (UE) 2022/2555 · D.Lgs. 138/2024</span></div>
<label class="search"><i data-lucide="search" class="ic"></i><input placeholder="Cerca misura, rischio, incidente…"></label>
<span class="spacer"></span>
<button class="btn-incident"><i data-lucide="siren" class="ic"></i><span>Registra incidente</span></button>
<span class="user"><span class="avatar">SF</span>Simone Fatteri<i data-lucide="chevron-down" class="ic"></i></span>
</div></div>
<nav class="bar-nav"><div class="bar-nav-in">
<div class="nav-item"><a class="nav-link" href="index.html"><i data-lucide="layout-dashboard" class="ic"></i><span>Dashboard</span></a></div>
<div class="nav-item"><a class="nav-link active" href="calendario.html"><i data-lucide="calendar" class="ic"></i><span>Calendario</span></a></div>
<div class="nav-item"><a class="nav-link" href="organizzazione.html"><i data-lucide="network" class="ic"></i><span>Organizzazione</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega"><div class="mega-col"><p class="mega-head">STRUTTURA</p><a class="mega-link" href="organizzazione.html"><span class="mega-ic"><i data-lucide="network" class="ic"></i></span><span><b>Organigramma</b><span>Ruoli, deleghe e responsabilità</span></span></a><a class="mega-link" href="organizzazione.html"><span class="mega-ic"><i data-lucide="award" class="ic"></i></span><span><b>Competenze</b><span>Skill matrix e copertura dei ruoli</span></span></a></div><div class="mega-col"><p class="mega-head">RELAZIONI</p><a class="mega-link" href="organizzazione.html"><span class="mega-ic"><i data-lucide="users" class="ic"></i></span><span><b>Stakeholder</b><span>Soggetti interni ed esterni rilevanti</span></span></a><a class="mega-link" href="organizzazione.html"><span class="mega-ic"><i data-lucide="activity" class="ic"></i></span><span><b>Attività stakeholder</b><span>Interazioni, richieste e impegni</span></span></a></div><div class="promo"><span class="badge">Apri</span><b>Mappa dei ruoli</b><p>Chi fa cosa nel perimetro NIS2: deleghe, competenze e stakeholder in un colpo d'occhio.</p><a class="go" href="organizzazione.html"><i data-lucide="arrow-right" class="ic"></i></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="rischi.html"><i data-lucide="shield-alert" class="ic"></i><span>Rischi & Incidenti</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega"><div class="mega-col"><p class="mega-head">GESTIONE RISCHIO</p><a class="mega-link" href="rischi.html"><span class="mega-ic"><i data-lucide="shield-alert" class="ic"></i></span><span><b>Rischi</b><span>Registro rischi e piani di trattamento</span></span></a><a class="mega-link" href="rischi.html"><span class="mega-ic"><i data-lucide="link" class="ic"></i></span><span><b>Supply Chain</b><span>Rischio fornitori e terze parti</span></span></a></div><div class="mega-col"><p class="mega-head">EVENTI</p><a class="mega-link" href="rischi.html"><span class="mega-ic"><i data-lucide="siren" class="ic"></i></span><span><b>Incidenti</b><span>Registrazione e notifica Art. 23</span></span></a><a class="mega-link" href="rischi.html"><span class="mega-ic"><i data-lucide="megaphone" class="ic"></i></span><span><b>Segnalazioni</b><span>Whistleblowing e segnalazioni interne</span></span></a></div><div class="promo"><span class="badge">Apri</span><b>Notifica Art. 23</b><p>Dall'incidente alla notifica all'autorità: tempistiche e stato in un unico flusso.</p><a class="go" href="rischi.html"><i data-lucide="arrow-right" class="ic"></i></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="policy.html"><i data-lucide="file-text" class="ic"></i><span>Policy</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega"><div class="mega-col"><p class="mega-head">DOCUMENTI</p><a class="mega-link" href="policy.html"><span class="mega-ic"><i data-lucide="file-text" class="ic"></i></span><span><b>Policy</b><span>Politiche approvate e in revisione</span></span></a><a class="mega-link" href="policy.html"><span class="mega-ic"><i data-lucide="book-open" class="ic"></i></span><span><b>Knowledge Base</b><span>Procedure, linee guida e riferimenti</span></span></a></div><div class="promo"><span class="badge">Apri</span><b>Genera Policy AI</b><p>Bozze di policy assistite dall'AI, allineate ai requisiti della Direttiva.</p><a class="go" href="policy.html"><i data-lucide="arrow-right" class="ic"></i></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="operativita.html"><i data-lucide="settings-2" class="ic"></i><span>Operatività</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega"><div class="mega-col"><p class="mega-head">ATTIVITÀ</p><a class="mega-link" href="operativita.html"><span class="mega-ic"><i data-lucide="graduation-cap" class="ic"></i></span><span><b>Formazione</b><span>Piani formativi e completamento</span></span></a><a class="mega-link" href="operativita.html"><span class="mega-ic"><i data-lucide="boxes" class="ic"></i></span><span><b>Inventario</b><span>Asset e perimetro tecnico</span></span></a></div><div class="mega-col"><p class="mega-head">CONTROLLI</p><a class="mega-link" href="operativita.html"><span class="mega-ic"><i data-lucide="clipboard-check" class="ic"></i></span><span><b>Audit Interni</b><span>Programma e esiti degli audit</span></span></a><a class="mega-link" href="operativita.html"><span class="mega-ic"><i data-lucide="calendar-clock" class="ic"></i></span><span><b>Controlli periodici</b><span>Verifiche ricorrenti pianificate</span></span></a><a class="mega-link" href="operativita.html"><span class="mega-ic"><i data-lucide="gavel" class="ic"></i></span><span><b>Riesame di Direzione</b><span>Riesame periodico della Direzione</span></span></a></div><div class="promo"><span class="badge">Apri</span><b>Calendario operativo</b><p>Formazione, audit e controlli periodici: scadenze sempre sotto controllo.</p><a class="go" href="operativita.html"><i data-lucide="arrow-right" class="ic"></i></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="report.html"><i data-lucide="bar-chart-3" class="ic"></i><span>Report</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega"><div class="mega-col"><p class="mega-head">OUTPUT</p><a class="mega-link" href="report.html"><span class="mega-ic"><i data-lucide="bar-chart-3" class="ic"></i></span><span><b>Audit & Report</b><span>Reportistica e dossier di conformità</span></span></a><a class="mega-link" href="report.html"><span class="mega-ic"><i data-lucide="calendar" class="ic"></i></span><span><b>Calendario</b><span>Vista temporale di scadenze ed eventi</span></span></a></div><div class="promo"><span class="badge">Apri</span><b>Dossier NIS2</b><p>Esporta lo stato di conformità in un dossier pronto per Direzione e autorità.</p><a class="go" href="report.html"><i data-lucide="arrow-right" class="ic"></i></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="ai.html"><i data-lucide="sparkles" class="ic"></i><span>AI</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">ASSISTENTE</p><a class="mega-link" href="ai.html"><span class="mega-ic"><i data-lucide="sparkles" class="ic"></i></span><span><b>AI Cross-Analysis</b><span>Analisi trasversale di rischi e misure</span></span></a><a class="mega-link" href="ai.html"><span class="mega-ic"><i data-lucide="book-open" class="ic"></i></span><span><b>Knowledge Base</b><span>Procedure, linee guida e riferimenti</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="guida.html"><i data-lucide="life-buoy" class="ic"></i><span>Guida</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">GUIDA</p><a class="mega-link" href="guida.html"><span class="mega-ic"><i data-lucide="life-buoy" class="ic"></i></span><span><b>Guida all'uso</b><span>Manuale e istruzioni della piattaforma</span></span></a><a class="mega-link" href="integrazioni.html"><span class="mega-ic"><i data-lucide="plug" class="ic"></i></span><span><b>Integrazioni Esterne</b><span>Connettori e servizi collegati</span></span></a></div><div class="mega-col"><p class="mega-head">SISTEMA</p><a class="mega-link" href="impostazioni.html"><span class="mega-ic"><i data-lucide="settings" class="ic"></i></span><span><b>Impostazioni</b><span>Configurazione del workspace</span></span></a><a class="mega-link" href="architettura.html"><span class="mega-ic"><i data-lucide="layers" class="ic"></i></span><span><b>Architettura</b><span>Modello dati e struttura del sistema</span></span></a></div></div></div>
</div></nav>
</header>
<main class="wrap">
<nav class="crumb"><i data-lucide="home" class="ic"></i><a href="index.html">Home</a><i data-lucide="chevron-right" class="ic"></i><span>Calendario</span></nav>
<div class="cal-app">
<aside class="cal-side">
<button class="btn-create" id="btn-create"><i data-lucide="plus" class="ic"></i>Crea evento</button>
<div class="mini">
<div class="mini-head"><b id="mini-label">giugno 2026</b>
<div class="mini-nav">
<button id="mini-prev" title="Mese precedente"><i data-lucide="chevron-left" class="ic"></i></button>
<button id="mini-next" title="Mese successivo"><i data-lucide="chevron-right" class="ic"></i></button>
</div>
</div>
<div class="mini-grid" id="mini-grid"></div>
</div>
<div class="legend">
<p>Categorie</p>
<div class="li"><i style="background:#C62828"></i>Incidenti</div><div class="li"><i style="background:#1E8A5A"></i>Formazione</div><div class="li"><i style="background:#1F5FA8"></i>Compliance / Governance</div><div class="li"><i style="background:#D98324"></i>Operatività</div>
</div>
</aside>
<section class="cal-main">
<div class="cal-toolbar">
<div class="tb-left">
<button class="ctrl-today" id="tb-today"><i data-lucide="calendar-check" class="ic"></i>Oggi</button>
<div class="tb-nav">
<button class="ctrl-btn" id="tb-prev" title="Precedente"><i data-lucide="chevron-left" class="ic"></i></button>
<button class="ctrl-btn" id="tb-next" title="Successivo"><i data-lucide="chevron-right" class="ic"></i></button>
</div>
<span class="tb-title" id="tb-title">giugno 2026</span>
</div>
<div class="view-switch">
<button data-v="month" class="on">Mese</button>
<button data-v="week">Settimana</button>
<button data-v="day">Giorno</button>
</div>
</div>
<div id="v-month" class="mv">
<div class="mv-dow"><div>Lun</div><div>Mar</div><div>Mer</div><div>Gio</div><div>Ven</div><div>Sab</div><div>Dom</div></div>
<div class="mv-grid" id="mv-grid"></div>
</div>
<div id="v-week" style="display:none"><div class="tg" id="tg-week"></div></div>
<div id="v-day" style="display:none"><div class="tg" id="tg-day"></div></div>
</section>
</div>
</main>
<div class="mbk" id="mbk">
<div class="modal">
<div class="modal-head"><h3 id="modal-title">Nuovo evento</h3>
<button class="x" onclick="closeModal()"><i data-lucide="x" class="ic"></i></button></div>
<div class="modal-body">
<div class="field"><label>Titolo</label><input id="m-title" placeholder="Es. Riunione referenti NIS2"></div>
<div class="field"><label>Data</label><input type="date" id="m-date"></div>
<label class="chk"><input type="checkbox" id="m-allday"> Tutto il giorno</label>
<div class="row2" id="time-row">
<div class="field"><label>Inizio</label><input type="time" id="m-start" value="09:00"></div>
<div class="field"><label>Fine</label><input type="time" id="m-end" value="10:00"></div>
</div>
<div class="field"><label>Colore</label><div class="cpick"><button data-c="blue" style="background:#1F5FA8" title="blue"></button><button data-c="green" style="background:#1E8A5A" title="green"></button><button data-c="amber" style="background:#D98324" title="amber"></button><button data-c="red" style="background:#C62828" title="red"></button></div></div>
</div>
<div class="modal-foot">
<button class="btn-del" id="btn-del" style="display:none"><i data-lucide="trash-2" class="ic"></i>Elimina</button>
<div class="foot-r">
<button class="btn-ghost" onclick="closeModal()">Annulla</button>
<button class="btn-primary" id="m-save">Salva</button>
</div>
</div>
</div>
</div>
<div class="fab-stack">
<button class="fab blue" title="Notifiche"><i data-lucide="bell" class="ic"></i></button>
<button class="fab red" title="Assistenza"><i data-lucide="life-buoy" class="ic"></i></button>
<button class="fab violet" title="Assistente AI"><i data-lucide="sparkles" class="ic"></i></button>
</div>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script>
const TODAY=new Date(2026,5,25);
const MOf=['gennaio','febbraio','marzo','aprile','maggio','giugno','luglio','agosto','settembre','ottobre','novembre','dicembre'];
const MOa=['gen','feb','mar','apr','mag','giu','lug','ago','set','ott','nov','dic'];
const WDfull=['luned\u00ec','marted\u00ec','mercoled\u00ec','gioved\u00ec','venerd\u00ec','sabato','domenica'];
const WDa=['Lun','Mar','Mer','Gio','Ven','Sab','Dom'];
const WDi=['L','M','M','G','V','S','D'];
const EVCOL={green:'#1E8A5A',blue:'#1F5FA8',amber:'#D98324',red:'#C62828'};
const START=7,END=21,HH=48,NH=END-START;
function mi(d){return (d.getDay()+6)%7;}
function addD(d,n){return new Date(d.getFullYear(),d.getMonth(),d.getDate()+n);}
function pad(n){return String(n).padStart(2,'0');}
function iso(d){return d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate());}
function monday(d){return addD(d,-mi(d));}
function same(a,b){return iso(a)===iso(b);}
function parseISO(s){const p=s.split('-');return new Date(+p[0],+p[1]-1,+p[2]);}
function toMin(t){const p=t.split(':');return (+p[0])*60+(+p[1]);}
let events=[
{id:1,title:'Formazione: gestione incidenti',date:'2026-06-25',start:'09:30',end:'11:00',color:'green'},
{id:2,title:'Review policy accessi',date:'2026-06-25',start:'14:00',end:'15:00',color:'blue'},
{id:3,title:'Notifica incidente Art. 23 \u2014 follow-up',date:'2026-06-26',start:'11:00',end:'12:00',color:'red'},
{id:4,title:'Riunione referenti NIS2',date:'2026-06-23',start:'15:00',end:'16:30',color:'blue'},
{id:5,title:'Audit interno \u2014 area IT',date:'2026-06-17',start:'10:00',end:'12:00',color:'green'},
{id:6,title:'Scadenza misura supply chain',date:'2026-06-20',allDay:true,color:'amber'},
{id:7,title:'Riesame di Direzione',date:'2026-06-30',start:'16:00',end:'17:30',color:'blue'},
{id:8,title:'Controllo periodico backup',date:'2026-07-01',start:'09:00',end:'10:00',color:'amber'},
{id:9,title:'Aggiornamento registro rischi',date:'2026-07-03',start:'12:00',end:'13:00',color:'amber'},
{id:10,title:'Audit fornitore critico',date:'2026-07-08',start:'10:30',end:'12:30',color:'green'},
{id:11,title:'Test piano risposta incidenti',date:'2026-07-10',start:'14:30',end:'16:00',color:'red'},
{id:12,title:'Formazione: notifica Art. 23',date:'2026-06-12',start:'09:00',end:'10:30',color:'green'},
{id:13,title:'Implementazione MFA',date:'2026-06-29',start:'09:30',end:'11:00',color:'blue'},
{id:14,title:'Verifica log e monitoraggio',date:'2026-06-28',start:'15:00',end:'16:00',color:'blue'}
];
let nextId=100;
let view='month';
let cursor=new Date(2026,5,25);
let selected=new Date(2026,5,25);
let miniMonth=new Date(2026,5,1);
let curColor='blue', editId=null;
const $=s=>document.querySelector(s);
function evsOf(d){return events.filter(e=>e.date===iso(d)).sort((a,b)=>(a.allDay?-1:0)-(b.allDay?-1:0)||(a.start||'').localeCompare(b.start||''));}
function renderAll(){renderTitle();renderMini();renderView();}
function renderTitle(){
const el=$('#tb-title');
if(view==='month'){el.textContent=MOf[cursor.getMonth()]+' '+cursor.getFullYear();}
else if(view==='week'){const m=monday(cursor),s=addD(m,6);
el.textContent=(m.getMonth()===s.getMonth()?m.getDate()+' \u2013 '+s.getDate()+' '+MOa[s.getMonth()]:m.getDate()+' '+MOa[m.getMonth()]+' \u2013 '+s.getDate()+' '+MOa[s.getMonth()])+' '+s.getFullYear();}
else{el.textContent=WDfull[mi(cursor)]+' '+cursor.getDate()+' '+MOf[cursor.getMonth()]+' '+cursor.getFullYear();}
}
function renderMini(){
$('#mini-label').textContent=MOf[miniMonth.getMonth()]+' '+miniMonth.getFullYear();
const g=$('#mini-grid');g.innerHTML='';
WDi.forEach(w=>{const h=document.createElement('div');h.className='h';h.textContent=w;g.appendChild(h);});
const start=monday(new Date(miniMonth.getFullYear(),miniMonth.getMonth(),1));
for(let i=0;i<42;i++){const d=addD(start,i);const c=document.createElement('div');
c.className='d'+(d.getMonth()!==miniMonth.getMonth()?' other':'')+(same(d,TODAY)?' today':'')+(same(d,selected)?' sel':'');
c.textContent=d.getDate();c.onclick=()=>setCursor(d);g.appendChild(c);}
}
function renderView(){
$('#v-month').style.display=view==='month'?'block':'none';
$('#v-week').style.display=view==='week'?'block':'none';
$('#v-day').style.display=view==='day'?'block':'none';
document.querySelectorAll('.view-switch button').forEach(b=>b.classList.toggle('on',b.dataset.v===view));
if(view==='month')renderMonth();else if(view==='week')renderWeek();else renderDay();
}
function chip(e){
const t=e.allDay?'':'<span class="t">'+e.start+'</span>';
return '<span class="chip" style="background:'+EVCOL[e.color]+'" data-ev="'+e.id+'">'+t+escapeHtml(e.title)+'</span>';
}
function escapeHtml(s){return s.replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function renderMonth(){
const grid=$('#mv-grid');grid.innerHTML='';
const first=new Date(cursor.getFullYear(),cursor.getMonth(),1);
const start=monday(first);
for(let i=0;i<42;i++){
const d=addD(start,i);const cell=document.createElement('div');
cell.className='mc'+(d.getMonth()!==cursor.getMonth()?' other':'')+(same(d,TODAY)?' today':'');
const evs=evsOf(d);
let html='<div class="dnum" data-day="'+iso(d)+'">'+d.getDate()+'</div>';
evs.slice(0,3).forEach(e=>html+=chip(e));
if(evs.length>3)html+='<div class="more" data-day="'+iso(d)+'">+'+(evs.length-3)+' altri</div>';
cell.innerHTML=html;
cell.onclick=(ev)=>{
const ce=ev.target.closest('[data-ev]'); if(ce){openEdit(+ce.dataset.ev);return;}
const dn=ev.target.closest('.dnum, .more'); if(dn){view='day';setCursor(parseISO(dn.dataset.day));return;}
openCreate(d,null);
};
grid.appendChild(cell);
}
}
function buildTimeGrid(container,days){
const cols='56px repeat('+days.length+',1fr)';
// head
let head='<div class="corner"></div>';
days.forEach(d=>{
const ad=evsOf(d).filter(e=>e.allDay).map(e=>'<span class="chip" style="background:'+EVCOL[e.color]+'" data-ev="'+e.id+'">'+escapeHtml(e.title)+'</span>').join('');
head+='<div class="tg-dh'+(same(d,TODAY)?' today':'')+'"><div class="wd">'+WDa[mi(d)]+'</div><div class="dn">'+d.getDate()+'</div><div class="ad">'+ad+'</div></div>';
});
// body
let body='';
for(let h=0;h<NH;h++){
body+='<div class="tg-hour" style="grid-column:1;grid-row:'+(h+1)+';height:'+HH+'px">'+pad(START+h)+':00</div>';
}
days.forEach((d,di)=>{
for(let h=0;h<NH;h++){
body+='<div class="tg-cell" style="grid-column:'+(di+2)+';grid-row:'+(h+1)+';height:'+HH+'px" data-day="'+iso(d)+'" data-hour="'+(START+h)+'"></div>';
}
// event overlay column
let ovEv='';
evsOf(d).filter(e=>!e.allDay).forEach(e=>{
const s=toMin(e.start),en=toMin(e.end||e.start);
const top=Math.max(0,(s-START*60)/60*HH);
const hgt=Math.max(22,(en-s)/60*HH);
ovEv+='<div class="tg-ev" style="top:'+top+'px;height:'+hgt+'px;background:'+EVCOL[e.color]+'" data-ev="'+e.id+'"><span class="tm">'+e.start+'\u2013'+(e.end||'')+'</span>'+escapeHtml(e.title)+'</div>';
});
body+='<div class="tg-colev" style="grid-column:'+(di+2)+';grid-row:1 / span '+NH+'">'+ovEv+'</div>';
});
container.innerHTML='<div class="tg-head" style="grid-template-columns:'+cols+'">'+head+'</div>'+
'<div class="tg-scroll"><div class="tg-body" style="grid-template-columns:'+cols+';grid-template-rows:repeat('+NH+','+HH+'px)">'+body+'</div></div>';
container.querySelectorAll('.tg-cell').forEach(c=>c.onclick=()=>openCreate(parseISO(c.dataset.day),+c.dataset.hour));
container.querySelectorAll('[data-ev]').forEach(c=>c.onclick=(ev)=>{ev.stopPropagation();openEdit(+c.dataset.ev);});
}
function renderWeek(){const m=monday(cursor);buildTimeGrid($('#tg-week'),Array.from({length:7},(_,i)=>addD(m,i)));}
function renderDay(){buildTimeGrid($('#tg-day'),[new Date(cursor)]);}
function setCursor(d){cursor=new Date(d);selected=new Date(d);miniMonth=new Date(d.getFullYear(),d.getMonth(),1);renderAll();}
function navTb(dir){
if(view==='month')cursor=new Date(cursor.getFullYear(),cursor.getMonth()+dir,1);
else if(view==='week')cursor=addD(cursor,7*dir);
else cursor=addD(cursor,dir);
miniMonth=new Date(cursor.getFullYear(),cursor.getMonth(),1);
if(view!=='month')selected=new Date(cursor);
renderAll();
}
// ---- modal ----
function openCreate(d,hour){
editId=null;$('#modal-title').textContent='Nuovo evento';$('#btn-del').style.display='none';
$('#m-title').value='';$('#m-date').value=iso(d);
$('#m-allday').checked=false;toggleAllday();
const sh=hour!=null?hour:9;
$('#m-start').value=pad(sh)+':00';$('#m-end').value=pad(Math.min(sh+1,23))+':00';
setColor('blue');showModal();$('#m-title').focus();
}
function openEdit(id){
const e=events.find(x=>x.id===id);if(!e)return;
editId=id;$('#modal-title').textContent='Modifica evento';$('#btn-del').style.display='inline-flex';
$('#m-title').value=e.title;$('#m-date').value=e.date;
$('#m-allday').checked=!!e.allDay;toggleAllday();
$('#m-start').value=e.start||'09:00';$('#m-end').value=e.end||'10:00';
setColor(e.color);showModal();
}
function setColor(c){curColor=c;document.querySelectorAll('.cpick button').forEach(b=>b.classList.toggle('sel',b.dataset.c===c));}
function toggleAllday(){const on=$('#m-allday').checked;$('#time-row').style.display=on?'none':'grid';}
function showModal(){$('#mbk').classList.add('show');}
function closeModal(){$('#mbk').classList.remove('show');}
function saveEvent(){
const title=$('#m-title').value.trim();if(!title){$('#m-title').focus();return;}
const allDay=$('#m-allday').checked;
const o={title,date:$('#m-date').value,color:curColor,allDay};
if(!allDay){o.start=$('#m-start').value;o.end=$('#m-end').value;}
if(editId){Object.assign(events.find(e=>e.id===editId),o,{id:editId});}
else{o.id=nextId++;events.push(o);}
closeModal();renderAll();
}
function delEvent(){if(editId){events=events.filter(e=>e.id!==editId);}closeModal();renderAll();}
// ---- wire ----
document.getElementById('tb-today').onclick=()=>setCursor(new Date(TODAY));
document.getElementById('tb-prev').onclick=()=>navTb(-1);
document.getElementById('tb-next').onclick=()=>navTb(1);
document.getElementById('mini-prev').onclick=()=>{miniMonth=new Date(miniMonth.getFullYear(),miniMonth.getMonth()-1,1);renderMini();};
document.getElementById('mini-next').onclick=()=>{miniMonth=new Date(miniMonth.getFullYear(),miniMonth.getMonth()+1,1);renderMini();};
document.querySelectorAll('.view-switch button').forEach(b=>b.onclick=()=>{view=b.dataset.v;renderView();renderTitle();});
document.getElementById('btn-create').onclick=()=>openCreate(selected,9);
document.getElementById('m-allday').onchange=toggleAllday;
document.querySelectorAll('.cpick button').forEach(b=>b.onclick=()=>setColor(b.dataset.c));
document.getElementById('m-save').onclick=saveEvent;
document.getElementById('btn-del').onclick=delEvent;
document.getElementById('mbk').addEventListener('click',e=>{if(e.target.id==='mbk')closeModal();});
renderAll();
window.lucide && lucide.createIcons();
</script>
</body></html>
+451
View File
@@ -0,0 +1,451 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard · NIS2 Agile</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{
--navy-top:#0A2E54; --navy-nav:#103A61; --navy-hover:#1B4A75; --gold:#F0B429;
--bg:#EFF4FA; --card:#FFFFFF; --ink:#15263C; --muted:#64748B; --line:#E3E9F0;
--c-green:#1E8A5A; --c-blue:#1F5FA8; --c-amber:#D98324; --c-red:#C62828;
--radius:12px; --shadow:0 1px 2px rgba(16,40,80,.06),0 8px 24px rgba(16,40,80,.06);
}
*{box-sizing:border-box} html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:'Inter',system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:14px;line-height:1.5}
a{color:inherit;text-decoration:none}
:focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:4px}
.appbar{position:sticky;top:0;z-index:50}
.bar-top{background:var(--navy-top);color:#fff}
.bar-top-in{max-width:1280px;margin:0 auto;display:flex;align-items:center;gap:16px;padding:10px 24px}
.flag{width:18px;height:13px;border-radius:2px;overflow:hidden;display:flex;flex:0 0 auto;box-shadow:0 0 0 1px rgba(255,255,255,.25)}
.flag i{flex:1}.flag .g{background:#1f8a4c}.flag .w{background:#fff}.flag .r{background:#cd2b2b}
.logo-mark{width:34px;height:34px;border-radius:8px;background:var(--c-green);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:800;font-size:13px;flex:0 0 auto}
.brand-txt{line-height:1.1;min-width:0}
.brand-txt b{font-size:15px;font-weight:700}
.brand-txt span{display:block;font-size:10.5px;color:#A9C2DC;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.search{flex:1;max-width:340px;display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.16);border-radius:8px;padding:7px 12px;color:#cfe0f1;min-width:120px}
.search input{flex:1;border:0;background:transparent;color:#fff;font:inherit;outline:none;min-width:0}
.search input::placeholder{color:#9db8d2}
.spacer{flex:1}
.btn-incident{display:inline-flex;align-items:center;gap:8px;background:#D24545;color:#fff;border:0;border-radius:8px;padding:9px 15px;font:inherit;font-weight:600;font-size:13px;cursor:pointer;box-shadow:0 1px 2px rgba(120,20,20,.35);white-space:nowrap}
.btn-incident:hover{background:#BF3A3A}
.btn-incident .ic{width:16px;height:16px}
.user{display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.16);border-radius:8px;padding:5px 10px 5px 5px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
.user:hover{background:rgba(255,255,255,.16)}
.avatar{width:24px;height:24px;border-radius:6px;background:var(--c-green);color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700}
.bar-nav{background:var(--navy-nav);border-top:1px solid rgba(255,255,255,.06)}
.bar-nav-in{max-width:1280px;margin:0 auto;padding:0 16px;display:flex;align-items:stretch;gap:2px;flex-wrap:wrap}
.nav-item{position:relative}
.nav-link{display:flex;align-items:center;gap:7px;color:#cfe0f1;font-weight:600;font-size:13px;padding:13px 14px;white-space:nowrap;border-bottom:3px solid transparent;cursor:pointer}
.nav-link .ic{width:15px;height:15px;opacity:.9}
.nav-link .caret{width:13px;height:13px;opacity:.7;margin-left:-2px}
.nav-item:hover>.nav-link{color:#fff;background:rgba(255,255,255,.06)}
.nav-link.active{color:#fff;border-bottom-color:var(--gold)}
.mega{position:absolute;top:100%;left:0;background:#fff;color:var(--ink);border-radius:0 0 14px 14px;box-shadow:0 18px 40px rgba(11,40,80,.18);border:1px solid var(--line);border-top:0;padding:22px;display:none;grid-template-columns:auto auto 250px;gap:30px;min-width:560px;z-index:60}
.nav-item:hover .mega,.nav-item:focus-within .mega{display:grid}
.nav-item:nth-last-child(-n+3) .mega{left:auto;right:0}
.mega.no-promo{min-width:auto}
.nav-item:hover .mega.no-promo,.nav-item:focus-within .mega.no-promo{display:flex;gap:30px;width:auto}
.mega-col{min-width:190px}
.mega-head{font-size:10.5px;font-weight:700;letter-spacing:.12em;color:#94A3B8;text-transform:uppercase;margin:0 0 12px}
.mega-link{display:flex;gap:11px;padding:8px;border-radius:9px;align-items:flex-start}
.mega-link:hover{background:#F3F7FC}
.mega-link .mega-ic{width:36px;height:36px;border-radius:10px;background:#EAF1FA;color:var(--c-blue);display:flex;align-items:center;justify-content:center;flex:0 0 auto}
.mega-ic .ic{width:21px;height:21px;display:block}
.mega-link b{display:block;font-size:13.5px;font-weight:700}
.mega-link span{display:block;font-size:11.5px;color:var(--muted);margin-top:2px}
.promo{background:linear-gradient(160deg,#1F5FA8,#0F3F77);color:#fff;border-radius:12px;padding:18px;display:flex;flex-direction:column;gap:10px}
.promo .badge{align-self:flex-start;background:rgba(255,255,255,.18);font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;padding:4px 9px;border-radius:999px}
.promo b{font-size:16px}
.promo p{margin:0;font-size:12px;color:#cfe0f3;line-height:1.45}
.promo .go{margin-top:auto;align-self:flex-end;width:38px;height:38px;border-radius:9px;background:#fff;color:var(--c-blue);display:flex;align-items:center;justify-content:center}
.wrap{max-width:1280px;margin:0 auto;padding:22px 24px 80px}
.crumb{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);margin-bottom:14px}
.crumb .ic{width:13px;height:13px}.crumb a:hover{color:var(--ink)}
.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:24px;flex-wrap:wrap}
.page-head h1{margin:0;font-size:26px;font-weight:800;letter-spacing:-.4px}
.page-head .sub{margin:4px 0 0;color:var(--muted);font-size:13.5px}
.date{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;background:#fff;border:1px solid var(--line);border-radius:9px;padding:8px 12px}
.date .ic{width:15px;height:15px}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow)}
.card-pad{padding:20px}
.stack{display:grid;gap:18px}
.cal-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;flex-wrap:wrap}
.cal-head h3{margin:0;font-size:15px;font-weight:700}
.cal-ctrl{display:flex;gap:6px;align-items:center}
.ctrl-btn{width:32px;height:32px;border:1px solid var(--line);border-radius:8px;background:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer}
.ctrl-btn:hover{background:#F5F8FC}
.ctrl-btn .ic{width:16px;height:16px;color:var(--muted)}
.ctrl-today{height:32px;padding:0 13px;border:1px solid var(--line);border-radius:8px;background:#fff;font:inherit;font-weight:600;font-size:12px;display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--ink)}
.ctrl-today:hover{background:#F5F8FC}
.ctrl-today .ic{width:14px;height:14px;color:var(--c-blue)}
.daystrip{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px;scrollbar-width:thin}
.daycell{flex:1 1 0;min-width:64px;border:1px solid var(--line);border-radius:11px;background:#fff;padding:11px 6px 9px;text-align:center;position:relative;cursor:pointer;transition:border-color .12s,transform .12s,box-shadow .12s}
.daycell:hover{border-color:#9FBEDD;box-shadow:0 4px 12px rgba(16,40,80,.08)}
.daycell .wd{font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#8093A8}
.daycell .dn{font-size:21px;font-weight:800;line-height:1.15;margin-top:3px}
.daycell .mo{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-top:1px}
.daycell.weekend{background:#F5F8FC}
.daycell.weekend .wd{color:#A6B4C6}
.daycell.today{background:var(--navy-nav);border-color:var(--navy-nav)}
.daycell.today:hover{border-color:var(--navy-hover);box-shadow:0 6px 16px rgba(16,40,80,.22)}
.daycell.today .wd{color:#C7D9EC}.daycell.today .dn,.daycell.today .mo{color:#fff}
.daycell.today::before{content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--gold);border-radius:11px 11px 0 0}
.dots{display:flex;gap:3px;justify-content:center;margin-top:7px;min-height:6px}
.ev{width:6px;height:6px;border-radius:50%;display:inline-block}
.ev.green{background:var(--c-green)}.ev.blue{background:var(--c-blue)}.ev.amber{background:var(--c-amber)}.ev.red{background:var(--c-red)}
.weekstrip{display:grid;grid-template-columns:1fr 1.25fr 1fr;gap:14px;align-items:start}
.weekblock{border:1px solid var(--line);border-radius:12px;background:#FAFCFE;padding:13px;cursor:pointer;transition:border-color .12s,box-shadow .12s}
.weekblock:hover{border-color:#9FBEDD;box-shadow:0 6px 16px rgba(16,40,80,.10)}
.weekblock.current{background:#fff;border-color:#B9CFE6;box-shadow:0 6px 18px rgba(16,40,80,.08)}
.weekblock .wl{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:8px}
.weekblock .wl b{font-size:12.5px;font-weight:700;color:var(--muted)}
.weekblock.current .wl b{color:var(--c-blue)}
.weekblock .wl span{font-size:9.5px;color:#A6B4C6;text-transform:uppercase;letter-spacing:.07em;font-weight:700;white-space:nowrap}
.week7{display:grid;grid-template-columns:repeat(7,1fr);gap:3px}
.mini{text-align:center;border-radius:8px;padding:6px 0 5px}
.mini .mi-wd{font-size:9.5px;font-weight:700;color:#9aa8ba;text-transform:uppercase}
.mini .mi-dn{font-size:13px;font-weight:700;margin-top:1px}
.mini.weekend .mi-wd,.mini.weekend .mi-dn{color:#A6B4C6}
.mini.today{background:var(--navy-nav)}.mini.today .mi-wd{color:#C7D9EC}.mini.today .mi-dn{color:#fff}
.mini .mi-ev{display:flex;gap:2px;justify-content:center;min-height:5px;margin-top:3px}
.mini .mi-ev .ev{width:4px;height:4px}
.cal-pop{position:fixed;z-index:80;display:none;width:252px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 14px 36px rgba(11,40,80,.20);padding:13px 14px;pointer-events:none;opacity:0;transform:translateY(5px);transition:opacity .12s ease,transform .12s ease}
.cal-pop.show{opacity:1;transform:none}
.cal-pop h4{margin:0 0 9px;font-size:12.5px;font-weight:700;text-transform:capitalize}
.cal-pop .pe{display:flex;gap:9px;align-items:flex-start;padding:6px 0;border-top:1px solid #EFF3F8}
.cal-pop .pe:first-of-type{border-top:0;padding-top:0}
.cal-pop .pe .ev{margin-top:5px;flex:0 0 auto}
.cal-pop .pe .tt{font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums;display:block}
.cal-pop .pe b{font-size:12px;font-weight:600;line-height:1.3;display:block}
.cal-pop .empty{font-size:12px;color:#94A3B8}
.fab-stack{position:fixed;right:22px;bottom:22px;display:flex;flex-direction:column;gap:12px;z-index:40}
.fab{width:46px;height:46px;border-radius:50%;border:0;color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 8px 20px rgba(16,40,80,.22)}
.fab .ic{width:20px;height:20px}
.fab.blue{background:var(--c-blue)}.fab.red{background:var(--c-red)}.fab.violet{background:#7C4DD6}
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.split2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
.split-anag{display:grid;grid-template-columns:1.35fr 1fr;gap:18px}
.sec-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
.sec-head h3{margin:0;font-size:15px;font-weight:700}
.sec-head .hint{font-size:11.5px;color:var(--muted)}
.stat{display:flex;gap:14px;align-items:flex-start;border-left:4px solid var(--c-blue);transition:box-shadow .12s,transform .12s}
a.stat{color:inherit}
a.stat:hover{box-shadow:0 8px 20px rgba(16,40,80,.13);transform:translateY(-1px)}
.stat.green{border-left-color:var(--c-green)}.stat.amber{border-left-color:var(--c-amber)}.stat.blue{border-left-color:var(--c-blue)}
.stat .ibox{width:42px;height:42px;border-radius:11px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;background:#EAF1FA;color:var(--c-blue)}
.stat.green .ibox{background:#E6F4EC;color:var(--c-green)}.stat.amber .ibox{background:#FBF1E2;color:var(--c-amber)}
.stat .ibox .ic{width:20px;height:20px}
.stat .lbl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#7a8aa0}
.stat .val{font-size:27px;font-weight:800;line-height:1.1;margin:3px 0 1px}
.stat.green .val{color:var(--c-green)}.stat.amber .val{color:var(--c-amber)}.stat.blue .val{color:var(--c-blue)}
.stat .sub{font-size:12px;color:var(--muted)}
.bar{height:7px;background:#EAF0F6;border-radius:99px;overflow:hidden;margin-top:9px;max-width:180px}
.bar>i{display:block;height:100%;background:var(--c-amber);border-radius:99px}
.btn-primary{display:inline-flex;align-items:center;gap:7px;background:var(--c-blue);color:#fff;border:0;border-radius:8px;padding:8px 13px;font:inherit;font-weight:600;font-size:12.5px;cursor:pointer}
.btn-primary:hover{background:#1A5494}
.btn-primary .ic{width:15px;height:15px}
.btn-ghost{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:7px 12px;font:inherit;font-weight:600;font-size:12.5px;color:var(--ink);cursor:pointer}
.btn-ghost:hover{background:#F5F8FC;border-color:#c7d4e4}
.btn-ghost .ic{width:15px;height:15px;color:var(--c-blue)}
.dpill{flex:0 0 auto;width:46px;text-align:center;background:#EFF4FB;border:1px solid var(--line);border-radius:9px;padding:6px 0}
.dpill b{display:block;font-size:15px;font-weight:800;line-height:1}
.dpill span{font-size:9.5px;font-weight:700;letter-spacing:.05em;color:var(--muted);text-transform:uppercase}
.sca{display:flex;gap:13px;align-items:flex-start;padding:12px 0;border-top:1px solid var(--line)}
.sca:first-of-type{border-top:0;padding-top:0}
.sca .body{flex:1;min-width:0}
.sca .body b{font-size:13px;font-weight:600;display:block}
.meta{display:flex;gap:8px;align-items:center;margin-top:5px;flex-wrap:wrap}
.who{font-size:11.5px;color:var(--muted)}
.badge-t{font-size:10px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;padding:2px 7px;border-radius:6px;background:#EEF2F7;color:#5b6b80;white-space:nowrap}
.badge-t.blue{background:#E7F0FB;color:#1F5FA8}.badge-t.amber{background:#FBF1E2;color:#9A6418}.badge-t.red{background:#FBE9E9;color:#B23636}.badge-t.green{background:#E6F4EC;color:#1E7A4E}.badge-t.gray{background:#EEF2F7;color:#5b6b80}
.act-scroll{max-height:330px;overflow-y:auto;padding-right:4px;scrollbar-width:thin}
.act{display:flex;gap:12px;align-items:flex-start;justify-content:space-between;padding:12px;border:1px solid var(--line);border-radius:10px;margin-bottom:8px;background:#fff}
.act:last-child{margin-bottom:0}
.act .body{flex:1;min-width:0}.act .body b{font-size:13px;font-weight:600;display:block}
.anag{display:flex;gap:16px;align-items:flex-start}
.anag-logo{width:60px;height:60px;border-radius:13px;background:linear-gradient(135deg,#143A5C,#1F5FA8);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:17px;letter-spacing:.5px;flex:0 0 auto}
.anag .info{flex:1;min-width:0}
.anag .nm{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.anag .nm h3{margin:0;font-size:16px;font-weight:800}
.badge-nis{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:6px;background:#E7F0FB;color:#1F5FA8}
.anag-grid{display:grid;grid-template-columns:1fr 1fr;gap:11px 18px;margin-top:14px}
.anag-grid .f span{display:block;font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#8093A8}
.anag-grid .f b{font-size:13px;font-weight:600}
.doc{display:flex;align-items:center;gap:12px;width:100%;text-align:left;border:1px solid var(--line);background:#fff;border-radius:10px;padding:12px 14px;cursor:pointer;font:inherit;margin-bottom:10px}
.doc:last-child{margin-bottom:0}
.doc:hover{border-color:#c7d4e4;background:#FAFCFE}
.doc .di{width:36px;height:36px;border-radius:9px;background:#EAF1FA;color:var(--c-blue);display:flex;align-items:center;justify-content:center;flex:0 0 auto}
.doc .di .ic{width:18px;height:18px}
.doc .dt{flex:1;min-width:0}
.doc .dt b{font-size:13.5px;font-weight:700;display:block}
.doc .dt small{font-size:11px;color:var(--muted)}
.doc .st{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-weight:700;padding:5px 10px;border-radius:999px;white-space:nowrap}
.doc .st .ic{width:14px;height:14px}
.doc.has .st{background:#E6F4EC;color:#1E7A4E}
.doc.miss .st{background:#FBF1E2;color:#9A6418}
@media (max-width:900px){.weekstrip{grid-template-columns:1fr}.grid3,.split2,.split-anag{grid-template-columns:1fr}.mega{grid-template-columns:1fr;min-width:280px}.search{display:none}}
@media (max-width:560px){.brand-txt span{display:none}.btn-incident span{display:none}}
</style>
</head>
<body>
<header class="appbar">
<div class="bar-top"><div class="bar-top-in">
<span class="flag"><i class="g"></i><i class="w"></i><i class="r"></i></span>
<div class="logo-mark">N2</div>
<div class="brand-txt"><b>NIS2 Agile</b><span>Conformità Direttiva (UE) 2022/2555 · D.Lgs. 138/2024</span></div>
<label class="search"><i data-lucide="search" class="ic"></i><input placeholder="Cerca misura, rischio, incidente…"></label>
<span class="spacer"></span>
<button class="btn-incident"><i data-lucide="siren" class="ic"></i><span>Registra incidente</span></button>
<span class="user"><span class="avatar">SF</span>Simone Fatteri<i data-lucide="chevron-down" class="ic"></i></span>
</div></div>
<nav class="bar-nav"><div class="bar-nav-in">
<div class="nav-item"><a class="nav-link active" href="index.html"><i data-lucide="layout-dashboard" class="ic"></i><span>Dashboard</span></a></div>
<div class="nav-item"><a class="nav-link" href="calendario.html"><i data-lucide="calendar" class="ic"></i><span>Calendario</span></a></div>
<div class="nav-item"><a class="nav-link" href="stakeholders.html"><i data-lucide="users" class="ic"></i><span>Stakeholders</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">RELAZIONI</p><a class="mega-link" href="stakeholders.html"><span class="mega-ic"><i data-lucide="handshake" class="ic"></i></span><span><b>Stakeholders</b><span>Soggetti interni ed esterni rilevanti</span></span></a><a class="mega-link" href="supply-chain.html"><span class="mega-ic"><i data-lucide="link" class="ic"></i></span><span><b>Supply-Chain</b><span>Rischio fornitori e terze parti</span></span></a><a class="mega-link" href="stakeholder-activities.html"><span class="mega-ic"><i data-lucide="activity" class="ic"></i></span><span><b>Attività su Stakeholders</b><span>Interazioni, richieste e impegni</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="organigramma.html"><i data-lucide="building-2" class="ic"></i><span>Struttura interna</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">RUOLI E COMPETENZE</p><a class="mega-link" href="organigramma.html"><span class="mega-ic"><i data-lucide="network" class="ic"></i></span><span><b>Organigramma</b><span>Ruoli, deleghe e responsabilità</span></span></a><a class="mega-link" href="competenze.html"><span class="mega-ic"><i data-lucide="award" class="ic"></i></span><span><b>Competenze</b><span>Skill matrix e copertura dei ruoli</span></span></a><a class="mega-link" href="assessment.html"><span class="mega-ic"><i data-lucide="git-compare" class="ic"></i></span><span><b>GAP Analysis</b><span>Scostamento tra stato attuale e atteso</span></span></a><a class="mega-link" href="training.html"><span class="mega-ic"><i data-lucide="graduation-cap" class="ic"></i></span><span><b>Formazione</b><span>Piani formativi e completamento</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="risks.html"><i data-lucide="shield-alert" class="ic"></i><span>Rischi</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">GESTIONE RISCHIO</p><a class="mega-link" href="risks.html"><span class="mega-ic"><i data-lucide="map" class="ic"></i></span><span><b>Mappa dei rischi</b><span>Registro rischi e piani di trattamento</span></span></a><a class="mega-link" href="assets.html"><span class="mega-ic"><i data-lucide="boxes" class="ic"></i></span><span><b>Inventario</b><span>Asset e perimetro tecnico</span></span></a><a class="mega-link" href="policies.html"><span class="mega-ic"><i data-lucide="file-text" class="ic"></i></span><span><b>Policy</b><span>Politiche approvate e in revisione</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="whistleblowing.html"><i data-lucide="radar" class="ic"></i><span>Monitoraggio</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">VIGILANZA E AUDIT</p><a class="mega-link" href="whistleblowing.html"><span class="mega-ic"><i data-lucide="megaphone" class="ic"></i></span><span><b>Segnalazioni ricevute</b><span>Whistleblowing e segnalazioni interne</span></span></a><a class="mega-link" href="internal-audits.html"><span class="mega-ic"><i data-lucide="clipboard-check" class="ic"></i></span><span><b>Audit interni</b><span>Programma e esiti degli audit</span></span></a><a class="mega-link" href="reports.html"><span class="mega-ic"><i data-lucide="file-check" class="ic"></i></span><span><b>Audit ACN</b><span>Verifiche e ispezioni dell'autorità</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="report.html"><i data-lucide="scale" class="ic"></i><span>Lex e ISO</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">NORMATIVA E ISO</p><a class="mega-link" href="normative.html"><span class="mega-ic"><i data-lucide="scroll-text" class="ic"></i></span><span><b>Normative</b><span>Direttiva, decreto e riferimenti applicabili</span></span></a><a class="mega-link" href="isms.html"><span class="mega-ic"><i data-lucide="shield-check" class="ic"></i></span><span><b>Modello SGSI (ISO 27001)</b><span>Sistema di gestione sicurezza informazioni</span></span></a><a class="mega-link" href="misure-requisiti.html"><span class="mega-ic"><i data-lucide="list-checks" class="ic"></i></span><span><b>Misure e requisiti ACN</b><span>Misure tecniche e organizzative Art. 21</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="ai.html"><i data-lucide="sparkles" class="ic"></i><span>AI</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">ASSISTENTE</p><a class="mega-link" href="cross-analysis.html"><span class="mega-ic"><i data-lucide="sparkles" class="ic"></i></span><span><b>AI Cross-Analysis</b><span>Analisi trasversale di rischi e misure</span></span></a><a class="mega-link" href="kb.html"><span class="mega-ic"><i data-lucide="book-open" class="ic"></i></span><span><b>Knowledge Base</b><span>Procedure, linee guida e riferimenti</span></span></a></div></div></div>
<div class="nav-item"><a class="nav-link" href="guida.html"><i data-lucide="life-buoy" class="ic"></i><span>Guida</span><i data-lucide="chevron-down" class="caret"></i></a><div class="mega no-promo"><div class="mega-col"><p class="mega-head">GUIDA</p><a class="mega-link" href="guida.html"><span class="mega-ic"><i data-lucide="life-buoy" class="ic"></i></span><span><b>Guida all'uso</b><span>Manuale e istruzioni della piattaforma</span></span></a><a class="mega-link" href="integrazioniext.html"><span class="mega-ic"><i data-lucide="plug" class="ic"></i></span><span><b>Integrazioni Esterne</b><span>Connettori e servizi collegati</span></span></a></div><div class="mega-col"><p class="mega-head">SISTEMA</p><a class="mega-link" href="setting.html"><span class="mega-ic"><i data-lucide="settings" class="ic"></i></span><span><b>Impostazioni</b><span>Configurazione del workspace</span></span></a><a class="mega-link" href="architecture.html"><span class="mega-ic"><i data-lucide="layers" class="ic"></i></span><span><b>Architettura</b><span>Modello dati e struttura del sistema</span></span></a></div></div></div>
</div></nav>
</header>
<main class="wrap">
<nav class="crumb"><i data-lucide="home" class="ic"></i><a href="index.html">Home</a><i data-lucide="chevron-right" class="ic"></i><span>Dashboard</span></nav>
<div class="page-head">
<div><h1>Dashboard</h1><p class="sub">Stato di conformità NIS2 del tuo perimetro</p></div>
<div class="date"><i data-lucide="calendar" class="ic"></i>giovedì 25 giugno 2026</div>
</div>
<div class="stack">
<section class="card card-pad">
<div class="cal-head">
<h3>Monitor Giornaliero</h3>
<div class="cal-ctrl">
<button class="ctrl-btn" id="d-prev" title="Giorni precedenti"><i data-lucide="chevron-left" class="ic"></i></button>
<button class="ctrl-today" id="d-today"><i data-lucide="calendar-check" class="ic"></i>Oggi</button>
<button class="ctrl-btn" id="d-next" title="Giorni successivi"><i data-lucide="chevron-right" class="ic"></i></button>
</div>
</div>
<div class="daystrip" id="daystrip"></div>
</section>
<section class="card card-pad">
<div class="cal-head">
<h3>Monitor Settimanale</h3>
<div class="cal-ctrl">
<button class="ctrl-btn" id="w-prev" title="Settimana precedente"><i data-lucide="chevron-left" class="ic"></i></button>
<button class="ctrl-today" id="w-today"><i data-lucide="calendar-check" class="ic"></i>Questa settimana</button>
<button class="ctrl-btn" id="w-next" title="Settimana successiva"><i data-lucide="chevron-right" class="ic"></i></button>
</div>
</div>
<div class="weekstrip" id="weekstrip"></div>
</section>
<div class="grid3">
<a href="assessment.html" class="card card-pad stat blue">
<div class="ibox"><i data-lucide="git-compare" class="ic"></i></div>
<div style="flex:1;min-width:0"><div class="lbl">Gap analysis in corso</div><div class="val">4</div><div class="sub">su 6 aree di valutazione</div></div>
</a>
<a href="training.html" class="card card-pad stat green">
<div class="ibox"><i data-lucide="graduation-cap" class="ic"></i></div>
<div style="flex:1;min-width:0"><div class="lbl">Formazione in corso</div><div class="val">3</div><div class="sub">corsi attivi alla data odierna</div></div>
</a>
<a href="risks.html" class="card card-pad stat amber">
<div class="ibox"><i data-lucide="shield-check" class="ic"></i></div>
<div style="flex:1;min-width:0"><div class="lbl">Rischi valutati</div><div class="val">68%</div><div class="sub">29 su 42 del registro rischi</div><div class="bar"><i style="width:68%"></i></div></div>
</a>
</div>
<div class="split2">
<section class="card card-pad">
<div class="sec-head"><h3>Scadenze · prossimi 10 giorni</h3><span class="hint">6 in agenda</span></div>
<div class="sca"><div class="dpill"><b>26</b><span>Giu</span></div><div class="body"><b>Notifica incidente Art. 23 — follow-up</b><div class="meta"><span class="badge-t red">Incidente</span><span class="who">M. Rossi</span></div></div></div><div class="sca"><div class="dpill"><b>28</b><span>Giu</span></div><div class="body"><b>Completamento corso «Gestione incidenti»</b><div class="meta"><span class="badge-t green">Formazione</span><span class="who">A. Ferretti</span></div></div></div><div class="sca"><div class="dpill"><b>30</b><span>Giu</span></div><div class="body"><b>Riesame di Direzione — chiusura trimestre</b><div class="meta"><span class="badge-t blue">Governance</span><span class="who">Direzione</span></div></div></div><div class="sca"><div class="dpill"><b>01</b><span>Lug</span></div><div class="body"><b>Controllo periodico backup e ripristini</b><div class="meta"><span class="badge-t amber">Controllo</span><span class="who">Reparto IT</span></div></div></div><div class="sca"><div class="dpill"><b>02</b><span>Lug</span></div><div class="body"><b>Revisione SoA — ISO/IEC 27001</b><div class="meta"><span class="badge-t blue">Compliance</span><span class="who">CISO</span></div></div></div><div class="sca"><div class="dpill"><b>03</b><span>Lug</span></div><div class="body"><b>Aggiornamento registro rischi</b><div class="meta"><span class="badge-t amber">Rischio</span><span class="who">Risk Owner</span></div></div></div>
</section>
<section class="card card-pad">
<div class="sec-head"><h3>Attività aperte</h3>
<button class="btn-primary" id="btn-nuova-att"><i data-lucide="plus" class="ic"></i>Nuova attività</button></div>
<div class="act-scroll" id="act-scroll"><div class="act"><div class="body"><b>Mappatura asset del perimetro NIS2</b><div class="meta"><span class="who">Reparto IT</span><span class="who">· scad. 4 lug</span></div></div><span class="badge-t blue">In corso</span></div><div class="act"><div class="body"><b>Definizione policy gestione accessi</b><div class="meta"><span class="who">CISO</span><span class="who">· scad. 2 lug</span></div></div><span class="badge-t amber">In revisione</span></div><div class="act"><div class="body"><b>Valutazione fornitori critici (supply chain)</b><div class="meta"><span class="who">Procurement</span><span class="who">· scad. 8 lug</span></div></div><span class="badge-t blue">In corso</span></div><div class="act"><div class="body"><b>Revisione contratti DPA fornitori</b><div class="meta"><span class="who">Ufficio Legale</span><span class="who">· scad. 20 giu</span></div></div><span class="badge-t red">In ritardo</span></div><div class="act"><div class="body"><b>Implementazione MFA accessi remoti</b><div class="meta"><span class="who">Reparto IT</span><span class="who">· scad. 30 giu</span></div></div><span class="badge-t blue">In corso</span></div><div class="act"><div class="body"><b>Procedura notifica Art. 23</b><div class="meta"><span class="who">Compliance</span><span class="who">· scad. 27 giu</span></div></div><span class="badge-t amber">In revisione</span></div><div class="act"><div class="body"><b>Piano formativo Art. 21</b><div class="meta"><span class="who">HR</span><span class="who">· scad. 12 lug</span></div></div><span class="badge-t blue">In corso</span></div><div class="act"><div class="body"><b>Gap analysis area OT</b><div class="meta"><span class="who">CISO</span><span class="who">· scad. 15 lug</span></div></div><span class="badge-t blue">In corso</span></div><div class="act"><div class="body"><b>Test piano di risposta incidenti</b><div class="meta"><span class="who">SOC</span><span class="who">· scad. 10 lug</span></div></div><span class="badge-t gray">Da avviare</span></div><div class="act"><div class="body"><b>Classificazione dati e asset</b><div class="meta"><span class="who">Data Owner</span><span class="who">· scad. 18 lug</span></div></div><span class="badge-t blue">In corso</span></div><div class="act"><div class="body"><b>Audit interno sicurezza fisica</b><div class="meta"><span class="who">Internal Audit</span><span class="who">· scad. 22 lug</span></div></div><span class="badge-t gray">Da avviare</span></div><div class="act"><div class="body"><b>Aggiornamento Business Continuity Plan</b><div class="meta"><span class="who">BCM</span><span class="who">· scad. 25 lug</span></div></div><span class="badge-t blue">In corso</span></div><div class="act"><div class="body"><b>Verifica log e monitoraggio continuo</b><div class="meta"><span class="who">SOC</span><span class="who">· scad. 28 giu</span></div></div><span class="badge-t blue">In corso</span></div><div class="act"><div class="body"><b>Aggiornamento registro incidenti</b><div class="meta"><span class="who">SOC</span><span class="who">· scad. 10 lug</span></div></div><span class="badge-t gray">Da avviare</span></div></div>
</section>
</div>
<div class="split-anag">
<section class="card card-pad">
<div class="sec-head"><h3>Anagrafica aziendale</h3>
<button class="btn-ghost" id="btn-edit-anag"><i data-lucide="pencil" class="ic"></i>Modifica</button></div>
<div class="anag">
<div class="anag-logo">AM</div>
<div class="info">
<div class="nm"><h3>Acme Manifatturiera S.p.A.</h3><span class="badge-nis">Soggetto importante</span></div>
<div class="anag-grid">
<div class="f"><span>Settore</span><b>Metalmeccanica · Manifatturiero</b></div>
<div class="f"><span>Partita IVA</span><b>IT 01234560360</b></div>
<div class="f"><span>Sede legale</span><b>Modena (MO), Italia</b></div>
<div class="f"><span>Dipendenti</span><b>248</b></div>
<div class="f"><span>Referente NIS2</span><b>Simone Fatteri</b></div>
<div class="f"><span>Registrazione ACN</span><b>Completata · 12/2024</b></div>
</div>
</div>
</div>
</section>
<section class="card card-pad">
<div class="sec-head"><h3>Documenti istituzionali</h3></div>
<button class="doc has" data-doc="Mission">
<span class="di"><i data-lucide="target" class="ic"></i></span>
<span class="dt"><b>Mission</b><small>mission_2026.pdf · 25 giu 2026</small></span>
<span class="st"><i data-lucide="check-circle-2" class="ic"></i>Allegato</span>
</button>
<button class="doc miss" data-doc="Vision">
<span class="di"><i data-lucide="eye" class="ic"></i></span>
<span class="dt"><b>Vision</b><small>Nessun file allegato</small></span>
<span class="st"><i data-lucide="upload" class="ic"></i>Da allegare</span>
</button>
<button class="doc has" data-doc="Etica aziendale">
<span class="di"><i data-lucide="scale" class="ic"></i></span>
<span class="dt"><b>Etica aziendale</b><small>codice_etico_v2.pdf · 18 giu 2026</small></span>
<span class="st"><i data-lucide="check-circle-2" class="ic"></i>Allegato</span>
</button>
</section>
</div>
</div>
</main>
<div class="fab-stack">
<button class="fab blue" title="Notifiche"><i data-lucide="bell" class="ic"></i></button>
<button class="fab red" title="Assistenza"><i data-lucide="life-buoy" class="ic"></i></button>
<button class="fab violet" title="Assistente AI"><i data-lucide="sparkles" class="ic"></i></button>
</div>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script>
const TODAY=new Date(2026,5,25);
const MO=['gen','feb','mar','apr','mag','giu','lug','ago','set','ott','nov','dic'];
const WDa=['Lun','Mar','Mer','Gio','Ven','Sab','Dom'];
const WDi=['L','M','M','G','V','S','D'];
const DAY=86400000;
function mi(d){return (d.getDay()+6)%7;}
function addD(d,n){return new Date(d.getFullYear(),d.getMonth(),d.getDate()+n);}
function iso(d){return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');}
function monday(d){return addD(d,-mi(d));}
function same(a,b){return iso(a)===iso(b);}
function weekLabel(m){const s=addD(m,6);return m.getMonth()===s.getMonth()?(m.getDate()+' \u2013 '+s.getDate()+' '+MO[s.getMonth()]):(m.getDate()+' '+MO[m.getMonth()]+' \u2013 '+s.getDate()+' '+MO[s.getMonth()]);}
const EVENTS={
'2026-06-12':[{t:'Formazione: notifica Art. 23 \u2014 A. Ferretti',c:'green',time:'09:00'}],
'2026-06-17':[{t:'Audit interno \u2014 area IT',c:'green',time:'10:00'}],
'2026-06-20':[{t:'Scadenza misura tecnica supply chain',c:'amber',time:'Tutto il giorno'}],
'2026-06-23':[{t:'Riunione referenti NIS2',c:'blue',time:'15:00'}],
'2026-06-25':[{t:'Formazione: gestione incidenti',c:'green',time:'09:30'},{t:'Review policy accessi',c:'blue',time:'14:00'}],
'2026-06-26':[{t:'Notifica incidente Art. 23 \u2014 follow-up',c:'red',time:'11:00'}],
'2026-06-30':[{t:'Riesame di Direzione \u2014 chiusura trimestre',c:'blue',time:'16:00'}],
'2026-07-01':[{t:'Controllo periodico backup',c:'amber',time:'09:00'}],
'2026-07-03':[{t:'Aggiornamento registro rischi',c:'amber',time:'12:00'}],
'2026-07-08':[{t:'Audit fornitore critico',c:'green',time:'10:30'}],
'2026-07-10':[{t:'Test piano di risposta incidenti',c:'red',time:'14:30'}]
};
function evOf(d){return EVENTS[iso(d)]||[];}
function dots(list){return list.slice(0,4).map(e=>'<i class="ev '+e.c+'"></i>').join('');}
let dayOffset=0, weekOffset=0;
const POP=document.createElement('div');POP.className='cal-pop';document.body.appendChild(POP);
let popTimer;
function showPop(anchor,html){
clearTimeout(popTimer);
POP.innerHTML=html;POP.style.display='block';
const r=anchor.getBoundingClientRect();
const pw=POP.offsetWidth, ph=POP.offsetHeight;
let left=r.left+r.width/2-pw/2;
left=Math.max(10,Math.min(left,window.innerWidth-pw-10));
let top=r.top-ph-10; if(top<10) top=r.bottom+10;
POP.style.left=left+'px';POP.style.top=top+'px';
requestAnimationFrame(()=>POP.classList.add('show'));
}
function hidePop(){POP.classList.remove('show');popTimer=setTimeout(()=>{POP.style.display='none';},150);}
function dayPop(d){
const list=evOf(d);
const head=WDa[mi(d)]+' '+d.getDate()+' '+MO[d.getMonth()];
let body=list.length
? list.map(e=>'<div class="pe"><span class="ev '+e.c+'"></span><div><span class="tt">'+e.time+'</span><b>'+e.t+'</b></div></div>').join('')
: '<div class="empty">Nessun appuntamento.</div>';
return '<h4>'+head+'</h4>'+body;
}
function weekPop(m){
let items=[];
for(let i=0;i<7;i++){const d=addD(m,i);evOf(d).forEach(e=>items.push({d:d,e:e}));}
let body=items.length
? items.slice(0,6).map(o=>'<div class="pe"><span class="ev '+o.e.c+'"></span><div><span class="tt">'+WDa[mi(o.d)]+' '+o.d.getDate()+'</span><b>'+o.e.t+'</b></div></div>').join('')
: '<div class="empty">Nessun appuntamento.</div>';
if(items.length>6) body+='<div class="empty" style="margin-top:6px">+'+(items.length-6)+' altri\u2026</div>';
return '<h4>'+weekLabel(m)+'</h4>'+body;
}
function openCal(){window.location.href='calendario.html';}
function renderDaily(){
const center=addD(TODAY,dayOffset);
const wrap=document.getElementById('daystrip');wrap.innerHTML='';
for(let i=-6;i<=6;i++){
const d=addD(center,i);
const cell=document.createElement('div');
cell.className='daycell'+(mi(d)>=5?' weekend':'')+(same(d,TODAY)?' today':'');
cell.innerHTML='<div class="wd">'+WDa[mi(d)]+'</div><div class="dn">'+d.getDate()+'</div><div class="mo">'+MO[d.getMonth()]+'</div><div class="dots">'+dots(evOf(d))+'</div>';
cell.addEventListener('mouseenter',()=>showPop(cell,dayPop(d)));
cell.addEventListener('mouseleave',hidePop);
cell.addEventListener('click',openCal);
wrap.appendChild(cell);
}
}
function renderWeekly(){
const curMon=monday(TODAY);
const centerMon=addD(curMon,weekOffset*7);
const wrap=document.getElementById('weekstrip');wrap.innerHTML='';
for(let w=-1;w<=1;w++){
const m=addD(centerMon,w*7);
const isCur=same(m,curMon);
const diff=Math.round((m-curMon)/(7*DAY));
let tag = diff===0?'Settimana in corso':diff===-1?'Settimana precedente':diff===1?'Settimana successiva':diff<0?(-diff)+' settimane fa':'Tra '+diff+' settimane';
const block=document.createElement('div');
block.className='weekblock'+(isCur?' current':'');
let minis='';
for(let i=0;i<7;i++){const d=addD(m,i);minis+='<div class="mini'+(mi(d)>=5?' weekend':'')+(same(d,TODAY)?' today':'')+'"><div class="mi-wd">'+WDi[mi(d)]+'</div><div class="mi-dn">'+d.getDate()+'</div><div class="mi-ev">'+dots(evOf(d))+'</div></div>';}
block.innerHTML='<div class="wl"><b>'+weekLabel(m)+'</b><span>'+tag+'</span></div><div class="week7">'+minis+'</div>';
block.addEventListener('mouseenter',()=>showPop(block,weekPop(m)));
block.addEventListener('mouseleave',hidePop);
block.addEventListener('click',openCal);
wrap.appendChild(block);
}
}
document.getElementById('d-prev').onclick=()=>{dayOffset--;renderDaily();};
document.getElementById('d-next').onclick=()=>{dayOffset++;renderDaily();};
document.getElementById('d-today').onclick=()=>{dayOffset=0;renderDaily();};
document.getElementById('w-prev').onclick=()=>{weekOffset--;renderWeekly();};
document.getElementById('w-next').onclick=()=>{weekOffset++;renderWeekly();};
document.getElementById('w-today').onclick=()=>{weekOffset=0;renderWeekly();};
renderDaily();renderWeekly();
document.querySelectorAll('.doc').forEach(d=>{
d.addEventListener('click',()=>{
const on=!d.classList.contains('has');
d.classList.toggle('has',on);d.classList.toggle('miss',!on);
const file=d.dataset.doc.toLowerCase().replace(/[^a-z0-9]+/g,'_')+'_2026.pdf';
d.querySelector('.dt small').textContent=on?(file+' \u00b7 25 giu 2026'):'Nessun file allegato';
d.querySelector('.st').innerHTML=on?'<i data-lucide="check-circle-2"></i>Allegato':'<i data-lucide="upload"></i>Da allegare';
lucide.createIcons();
});
});
const bn=document.getElementById('btn-nuova-att');
if(bn) bn.addEventListener('click',()=>{
const wrap=document.getElementById('act-scroll');
const el=document.createElement('div');el.className='act';
el.innerHTML='<div class="body"><b>Nuova attivit\u00e0 (bozza)</b><div class="meta"><span class="who">Responsabile da assegnare</span><span class="who">\u00b7 scadenza \u2014</span></div></div><span class="badge-t gray">Bozza</span>';
wrap.prepend(el);wrap.scrollTop=0;
});
window.lucide && lucide.createIcons();
</script>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
-- 063_vocea_channel.sql — Integrazione Vocea (whistleblowing zero-knowledge).
-- Mappa ogni organization NIS2 al proprio canale Vocea (tenant). ADDITIVA, non distruttiva.
-- Apply autoritativo IDEMPOTENTE: application/cli/migrate_063_vocea_channel.php
-- (MySQL non supporta ADD COLUMN IF NOT EXISTS → l'apply controlla information_schema).
-- SCAFFOLD DORMIENTE: nessun effetto funzionale finché VOCEA_ENABLED=false (default).
ALTER TABLE organizations
ADD COLUMN vocea_tenant_slug VARCHAR(64) NULL COMMENT 'Slug del canale Vocea associato (NULL = nessun canale)';
ALTER TABLE organizations
ADD COLUMN vocea_channel_status ENUM('none','provisioning','active','suspended') NOT NULL DEFAULT 'none';
ALTER TABLE organizations
ADD COLUMN vocea_enrolled_at TIMESTAMP NULL COMMENT 'Quando canale + key holder sono stati enrollati';
+57
View File
@@ -0,0 +1,57 @@
-- 064_discovery_connectors.sql — Connettori di discovery (mappatura rete/cloud) per-org.
-- Più connettori per organizzazione; l'agente esterno autentica con una api_key dedicata
-- (scope ingest:assets) e auto-popola Inventario + dipendenze + flussi di rete (ID.AM-03).
-- Apply idempotente: application/cli/migrate_064_discovery_connectors.php
-- Additiva, non distruttiva.
CREATE TABLE IF NOT EXISTS discovery_connectors (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
name VARCHAR(120) NOT NULL, -- es. "Rete sede Modena", "AWS prod"
connector_type ENUM('network','aws','azure','gcp','cmdb','agent','custom') NOT NULL DEFAULT 'network',
config JSON NULL, -- CIDR target, account/region cloud, opzioni
api_key_id INT UNSIGNED NULL, -- chiave usata dall'agente (api_keys.id)
status ENUM('active','paused','error') NOT NULL DEFAULT 'active',
schedule ENUM('manual','hourly','daily','weekly') NOT NULL DEFAULT 'manual',
last_run_at TIMESTAMP NULL,
last_status VARCHAR(20) NULL, -- ok|error|running
last_discovered INT NULL, -- n. asset scoperti all'ultimo run
last_message VARCHAR(255) NULL,
created_by INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_dc_org_name (organization_id, name),
INDEX idx_dc_org (organization_id),
INDEX idx_dc_key (api_key_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS discovery_runs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
connector_id INT UNSIGNED NOT NULL,
organization_id INT NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP NULL,
status ENUM('running','ok','error') NOT NULL DEFAULT 'ok',
discovered_assets INT NOT NULL DEFAULT 0,
discovered_flows INT NOT NULL DEFAULT 0,
message VARCHAR(255) NULL,
INDEX idx_dr_conn (connector_id),
INDEX idx_dr_org (organization_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Flussi di rete (ID.AM-03): inventario dei flussi tra sistemi e verso l'esterno.
CREATE TABLE IF NOT EXISTS network_flows (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
src VARCHAR(190) NOT NULL, -- host/asset sorgente (ip o nome)
dst VARCHAR(190) NOT NULL, -- host/asset destinazione (ip o nome)
port INT NULL,
protocol VARCHAR(12) NULL, -- tcp|udp|icmp
direction ENUM('internal','inbound','outbound') NOT NULL DEFAULT 'internal',
discovery_source VARCHAR(40) NOT NULL DEFAULT 'manual', -- network/aws/...; manual
external_ref VARCHAR(190) NULL, -- chiave dedup dal sorgente
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_flow (organization_id, external_ref),
INDEX idx_flow_org (organization_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+19
View File
@@ -0,0 +1,19 @@
-- 065_institutional_documents.sql — Documenti istituzionali per-org (dashboard).
-- Ticket #499 (Simon Fattori, super_admin): ad ogni documento una DESCRIZIONE obbligatoria
-- inserita dall'utente + 5 voci standard sempre presenti (Codice Etico, Mission, Vision,
-- Statuto, Piano Sanzionatorio). Additiva e idempotente.
CREATE TABLE IF NOT EXISTS institutional_documents (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
doc_key VARCHAR(60) NULL, -- codice_etico|mission|vision|statuto|piano_sanzionatorio; NULL = voce custom
title VARCHAR(200) NOT NULL,
description TEXT NOT NULL, -- obbligatoria (richiesta punto 1 del ticket)
file_url VARCHAR(500) NULL,
is_standard TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_by INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_instdoc_org_key (organization_id, doc_key),
INDEX idx_instdoc_org (organization_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+8
View File
@@ -0,0 +1,8 @@
-- 066_requisito_risk_scoring.sql — Ticket #500 (opzione B, matrice 5x5 dei rischi derivati).
-- Aggiunge probabilita/impatto (1-5) alla valutazione per-requisito, così i rischi
-- derivati dai requisiti (RSK-<misura>Com.N) si posizionano sulla matrice.
-- Additiva e idempotente (i valori NULL = non ancora valutato). Runner:
-- application/cli/migrate_066_requisito_risk_scoring.php
ALTER TABLE org_requisito_state
ADD COLUMN likelihood TINYINT NULL COMMENT 'Probabilita 1-5' AFTER valutazione_rischio,
ADD COLUMN impact TINYINT NULL COMMENT 'Impatto 1-5' AFTER likelihood;
@@ -0,0 +1,25 @@
-- 067_requisito_residual_and_actions.sql — Integrazione prototipo "Modulo Azioni" (Simon), Fase 1.
-- (1) org_requisito_state += valutazione del RISCHIO RESIDUO (dopo il trattamento):
-- residual_likelihood / residual_impact (1-5). L'inerente resta likelihood/impact (mig 066).
-- (2) requisito_actions: azioni collegate al rischio-da-requisito (seme del modulo Azioni).
-- NB normativo: NON tocca lo stato di conformità del requisito (org_requisito_state.stato),
-- che resta un atto esplicito di attuazione+evidenza. Vedi eccezione #1 (parere nis2-expert).
-- Additiva e idempotente. Runner: application/cli/migrate_067_requisito_residual_and_actions.php
ALTER TABLE org_requisito_state
ADD COLUMN residual_likelihood TINYINT NULL COMMENT 'Probabilita residua 1-5 (dopo trattamento)' AFTER impact,
ADD COLUMN residual_impact TINYINT NULL COMMENT 'Impatto residuo 1-5 (dopo trattamento)' AFTER residual_likelihood;
CREATE TABLE IF NOT EXISTS requisito_actions (
id INT AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
requisito_id INT NOT NULL,
risk_code VARCHAR(64) NULL,
description TEXT NOT NULL,
status ENUM('bozza','in_corso','archiviata') NOT NULL DEFAULT 'bozza',
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_reqact_org_req (organization_id, requisito_id, created_at),
INDEX idx_reqact_org (organization_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+12
View File
@@ -1,5 +1,17 @@
RewriteEngine On RewriteEngine On
# ══════════════════════════════════════════════════════════════════════════
# MANUTENZIONE (flag-gated): se esiste <docroot>/../.maintenance, OGNI richiesta
# → HTTP 503 + maintenance.html. Bypass: ?mnt=<token> o cookie nis2_mnt=<token>.
# Accendere: touch ../.maintenance · Spegnere: rm ../.maintenance (istantaneo)
# ══════════════════════════════════════════════════════════════════════════
ErrorDocument 503 /maintenance.html
RewriteCond %{DOCUMENT_ROOT}/../.maintenance -f
RewriteCond %{REQUEST_URI} !^/maintenance\.html$
RewriteCond %{QUERY_STRING} !(^|&)mnt=efc088d042032889b64b887d2e562ec8(&|$)
RewriteCond %{HTTP_COOKIE} !nis2_mnt=efc088d042032889b64b887d2e562ec8
RewriteRule ^ - [R=503,L]
# Pass Authorization header to PHP-FPM # Pass Authorization header to PHP-FPM
RewriteCond %{HTTP:Authorization} .+ RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
+3 -3
View File
@@ -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=20260631"></script> <script src="js/api.js?v=20260730a"></script>
<script src="js/common.js?v=20260631"></script> <script src="js/common.js?v=20260729b"></script>
<script src="js/common-bi.js?v=20260631"></script> <script src="js/common-bi.js?v=20260625d"></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 () {
+139 -299
View File
@@ -3,184 +3,34 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Pannello Amministrazione - NIS2 Agile</title> <title>Pannello Amministrazione · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css. Pagina in /admin/: asset con path ASSOLUTI. -->
cosi' style.css resta autorevole per i widget renderizzati via JS -> parita' funzionale. <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
Pagina in /admin/: gli asset usano path ASSOLUTI (/vendor, /js, /css) serviti dal web root. --> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<style> <style>
.admin-stats-grid { /* ── Grafico distribuzione piani (non coperto da v3.css) ────────── */
display: grid; .chart-container { padding:8px 4px 4px; }
grid-template-columns: repeat(4, 1fr); .bar-chart { display:flex; align-items:flex-end; gap:32px; height:200px; padding:0 20px; border-bottom:2px solid var(--line); }
gap: 16px; .bar-chart-item { display:flex; flex-direction:column; align-items:center; flex:1; height:100%; justify-content:flex-end; }
margin-bottom: 24px; .bar-chart-bar { width:60px; border-radius:6px 6px 0 0; transition:height .8s ease; min-height:4px; position:relative; }
} .bar-chart-bar.free { background:#94A3B8; }
.admin-stat-card { .bar-chart-bar.professional { background:var(--c-blue); }
background: var(--card-bg); .bar-chart-bar.enterprise { background:#7C4DD6; }
border-radius: var(--border-radius-lg); .bar-chart-value { font-size:.875rem; font-weight:700; color:var(--ink); margin-bottom:8px; }
box-shadow: var(--card-shadow); .bar-chart-label { font-size:.75rem; color:var(--muted); margin-top:12px; font-weight:600; text-transform:uppercase; letter-spacing:.03em; }
padding: 20px; @media (prefers-reduced-motion:reduce) { .bar-chart-bar{transition:none;} }
transition: box-shadow var(--transition), transform var(--transition);
} /* Quick links */
.admin-stat-card:hover { .quick-links-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:16px; }
box-shadow: var(--card-shadow-hover); .quick-link-card { display:flex; align-items:center; gap:16px; padding:18px 20px; background:#fff; border:1px solid var(--line); border-radius:var(--radius); cursor:pointer; transition:border-color .12s, box-shadow .12s, transform .12s; text-decoration:none; color:inherit; }
transform: translateY(-2px); .quick-link-card:hover { border-color:#9FBEDD; box-shadow:0 6px 16px rgba(16,40,80,.10); transform:translateY(-1px); }
} .quick-link-icon { width:46px; height:46px; border-radius:11px; background:#EAF1FA; color:var(--c-blue); display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.admin-stat-label { .quick-link-icon .ic { width:22px; height:22px; }
font-size: 0.7rem; .quick-link-info h4 { font-size:.9375rem; font-weight:700; color:var(--ink); margin:0 0 2px; }
font-weight: 600; .quick-link-info p { font-size:.8125rem; color:var(--muted); margin:0; }
text-transform: uppercase;
letter-spacing: 0.04em; @media (max-width:1024px) { .grid3 { grid-template-columns:repeat(2,1fr); } }
color: var(--gray-500); @media (max-width:768px) { .grid3 { grid-template-columns:1fr; } .quick-links-grid { grid-template-columns:1fr; } .bar-chart { gap:16px; } .bar-chart-bar { width:40px; } }
margin-bottom: 8px;
}
.admin-stat-value {
font-size: 2rem;
font-weight: 800;
color: var(--gray-900);
line-height: 1;
}
.admin-stat-icon {
width: 40px;
height: 40px;
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.admin-stat-icon svg {
width: 20px;
height: 20px;
}
.admin-stat-icon.blue { background: var(--primary-bg); color: var(--primary); }
.admin-stat-icon.green { background: var(--secondary-bg); color: var(--secondary); }
.admin-stat-icon.orange { background: var(--warning-bg); color: #d97706; }
.admin-stat-icon.red { background: var(--danger-bg); color: var(--danger); }
.admin-stat-icon.purple { background: #f3e8ff; color: #7c3aed; }
/* Le icone BI usano fill=currentColor: garantiamo che il colore del wrapper sia ereditato. */
.admin-stat-icon svg.icon { fill: currentColor; width: 20px; height: 20px; }
.chart-container {
padding: 24px;
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 32px;
height: 200px;
padding: 0 20px;
border-bottom: 2px solid var(--gray-200);
}
.bar-chart-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
height: 100%;
justify-content: flex-end;
}
.bar-chart-bar {
width: 60px;
border-radius: 6px 6px 0 0;
transition: height 0.8s ease;
min-height: 4px;
position: relative;
}
.bar-chart-bar.free { background: var(--gray-400); }
.bar-chart-bar.professional { background: var(--primary); }
.bar-chart-bar.enterprise { background: #7c3aed; }
.bar-chart-value {
font-size: 0.875rem;
font-weight: 700;
color: var(--gray-700);
margin-bottom: 8px;
}
.bar-chart-label {
font-size: 0.75rem;
color: var(--gray-500);
margin-top: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.quick-link-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius-lg);
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
color: inherit;
}
.quick-link-card:hover {
border-color: var(--primary);
background: var(--primary-bg);
color: var(--primary);
transform: translateY(-2px);
box-shadow: var(--card-shadow-hover);
}
.quick-link-card:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.quick-link-icon {
width: 48px;
height: 48px;
border-radius: var(--border-radius);
background: var(--primary-bg);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.quick-link-icon svg {
width: 24px;
height: 24px;
fill: currentColor;
}
.quick-link-info h4 {
font-size: 0.9375rem;
font-weight: 600;
color: var(--gray-800);
margin-bottom: 2px;
}
.quick-link-info p {
font-size: 0.8125rem;
color: var(--gray-500);
}
.quick-link-card:hover .quick-link-info h4 {
color: var(--primary);
}
.quick-links-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
@media (max-width: 1024px) {
.admin-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.admin-stats-grid {
grid-template-columns: 1fr;
}
.quick-links-grid {
grid-template-columns: 1fr;
}
.bar-chart {
gap: 16px;
}
.bar-chart-bar {
width: 40px;
}
}
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -197,144 +47,134 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content"> <main class="wrap">
<header class="content-header"> <nav class="crumb" aria-label="breadcrumb">
<h1 class="h2">Pannello Amministrazione</h1> <i data-lucide="home" class="ic"></i><a href="/dashboard.html">Home</a>
<div class="content-header-actions"> <i data-lucide="chevron-right" class="ic"></i><span>Amministrazione</span>
<span class="badge badge-danger">Super Admin</span> </nav>
</div> <div class="page-head">
</header> <div>
<h1>Pannello Amministrazione</h1>
<p class="sub">Panoramica della piattaforma e gestione globale</p>
</div>
<span class="badge-t red">Super Admin</span>
</div>
<div class="content-body"> <div class="stack">
<!-- Stats Row 1 --> <!-- Stats Row 1 -->
<div class="admin-stats-grid" id="stats-row-1"> <div class="grid3" id="stats-row-1" style="grid-template-columns:repeat(4,1fr)">
<div class="admin-stat-card"> <div class="card card-pad stat blue">
<div class="admin-stat-icon blue" aria-hidden="true"> <div class="ibox"><i data-lucide="building-2" class="ic"></i></div>
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg> <div style="flex:1;min-width:0">
</div> <div class="lbl" id="lbl-total-orgs">Totale Organizzazioni</div>
<div class="admin-stat-label" id="lbl-total-orgs">Totale Organizzazioni</div> <div class="val" id="stat-total-orgs" aria-labelledby="lbl-total-orgs">--</div>
<div class="admin-stat-value" id="stat-total-orgs" aria-labelledby="lbl-total-orgs">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon green" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check-circle"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-active-orgs">Organizzazioni Attive</div>
<div class="admin-stat-value" id="stat-active-orgs" aria-labelledby="lbl-active-orgs">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon blue" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-total-users">Totale Utenti</div>
<div class="admin-stat-value" id="stat-total-users" aria-labelledby="lbl-total-users">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon green" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-active-users">Utenti Attivi</div>
<div class="admin-stat-value" id="stat-active-users" aria-labelledby="lbl-active-users">--</div>
</div> </div>
</div> </div>
<div class="card card-pad stat green">
<!-- Stats Row 2 --> <div class="ibox"><i data-lucide="check-circle" class="ic"></i></div>
<div class="admin-stats-grid" id="stats-row-2"> <div style="flex:1;min-width:0">
<div class="admin-stat-card"> <div class="lbl" id="lbl-active-orgs">Organizzazioni Attive</div>
<div class="admin-stat-icon purple" aria-hidden="true"> <div class="val" id="stat-active-orgs" aria-labelledby="lbl-active-orgs">--</div>
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-assessments">Assessment Completati</div>
<div class="admin-stat-value" id="stat-assessments" aria-labelledby="lbl-assessments">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon orange" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning-circle"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-incidents">Incidenti Aperti</div>
<div class="admin-stat-value" id="stat-incidents" aria-labelledby="lbl-incidents">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon red" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-error"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-risks">Totale Rischi</div>
<div class="admin-stat-value" id="stat-risks" aria-labelledby="lbl-risks">--</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-icon purple" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-software"></use></svg>
</div>
<div class="admin-stat-label" id="lbl-ai">Interazioni AI</div>
<div class="admin-stat-value" id="stat-ai" aria-labelledby="lbl-ai">--</div>
</div> </div>
</div> </div>
<div class="card card-pad stat blue">
<!-- Plans Distribution --> <div class="ibox"><i data-lucide="users" class="ic"></i></div>
<div class="card mb-24"> <div style="flex:1;min-width:0">
<div class="card-header"> <div class="lbl" id="lbl-total-users">Totale Utenti</div>
<h3>Distribuzione Piani</h3> <div class="val" id="stat-total-users" aria-labelledby="lbl-total-users">--</div>
</div>
<div class="chart-container">
<div class="bar-chart" id="plans-chart" role="img" aria-label="Grafico a barre: distribuzione delle organizzazioni per piano di sottoscrizione">
<div class="spinner" style="margin:40px auto;"></div>
</div>
</div> </div>
</div> </div>
<div class="card card-pad stat green">
<!-- Quick Links --> <div class="ibox"><i data-lucide="user-check" class="ic"></i></div>
<div class="card"> <div style="flex:1;min-width:0">
<div class="card-header"> <div class="lbl" id="lbl-active-users">Utenti Attivi</div>
<h3>Gestione Piattaforma</h3> <div class="val" id="stat-active-users" aria-labelledby="lbl-active-users">--</div>
</div>
<div class="card-body">
<nav class="quick-links-grid" aria-label="Collegamenti rapidi gestione piattaforma">
<a href="/admin/organizations.html" class="quick-link-card">
<div class="quick-link-icon" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg>
</div>
<div class="quick-link-info">
<h4>Gestisci Organizzazioni</h4>
<p>Visualizza e gestisci tutte le organizzazioni registrate sulla piattaforma.</p>
</div>
</a>
<a href="/admin/users.html" class="quick-link-card">
<div class="quick-link-icon" aria-hidden="true">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
</div>
<div class="quick-link-info">
<h4>Gestisci Utenti</h4>
<p>Visualizza e gestisci tutti gli utenti registrati sulla piattaforma.</p>
</div>
</a>
</nav>
</div> </div>
</div> </div>
</div> </div>
</main>
</div>
<script src="/js/api.js?v=20260620"></script> <!-- Stats Row 2 -->
<script src="/js/common.js?v=20260620"></script> <div class="grid3" id="stats-row-2" style="grid-template-columns:repeat(4,1fr)">
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <div class="card card-pad stat blue">
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, <div class="ibox"><i data-lucide="clipboard-check" class="ic"></i></div>
riusando tutti gli helper di common.js (zero backend). --> <div style="flex:1;min-width:0">
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <div class="lbl" id="lbl-assessments">Assessment Completati</div>
<script> <div class="val" id="stat-assessments" aria-labelledby="lbl-assessments">--</div>
if (window.bootstrap && bootstrap.loadFonts) { </div>
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); </div>
} <div class="card card-pad stat amber">
</script> <div class="ibox"><i data-lucide="alert-triangle" class="ic"></i></div>
<script src="/js/common-bi.js?v=20260618"></script> <div style="flex:1;min-width:0">
<script src="/js/i18n.js?v=20260620"></script> <div class="lbl" id="lbl-incidents">Incidenti Aperti</div>
<div class="val" id="stat-incidents" aria-labelledby="lbl-incidents">--</div>
</div>
</div>
<div class="card card-pad stat amber">
<div class="ibox"><i data-lucide="shield-alert" class="ic"></i></div>
<div style="flex:1;min-width:0">
<div class="lbl" id="lbl-risks">Totale Rischi</div>
<div class="val" id="stat-risks" aria-labelledby="lbl-risks">--</div>
</div>
</div>
<div class="card card-pad stat blue">
<div class="ibox"><i data-lucide="sparkles" class="ic"></i></div>
<div style="flex:1;min-width:0">
<div class="lbl" id="lbl-ai">Interazioni AI</div>
<div class="val" id="stat-ai" aria-labelledby="lbl-ai">--</div>
</div>
</div>
</div>
<!-- Plans Distribution -->
<section class="card card-pad">
<div class="sec-head"><h3>Distribuzione Piani</h3></div>
<div class="chart-container">
<div class="bar-chart" id="plans-chart" role="img" aria-label="Grafico a barre: distribuzione delle organizzazioni per piano di sottoscrizione">
<div class="v3-loading" style="flex:1"><span class="v3-spin"></span>Caricamento…</div>
</div>
</div>
</section>
<!-- Quick Links -->
<section class="card card-pad">
<div class="sec-head"><h3>Gestione Piattaforma</h3></div>
<nav class="quick-links-grid" aria-label="Collegamenti rapidi gestione piattaforma">
<a href="/admin/organizations.html" class="quick-link-card">
<div class="quick-link-icon" aria-hidden="true"><i data-lucide="building-2" class="ic"></i></div>
<div class="quick-link-info">
<h4>Gestisci Organizzazioni</h4>
<p>Visualizza e gestisci tutte le organizzazioni registrate sulla piattaforma.</p>
</div>
</a>
<a href="/admin/users.html" class="quick-link-card">
<div class="quick-link-icon" aria-hidden="true"><i data-lucide="users" class="ic"></i></div>
<div class="quick-link-info">
<h4>Gestisci Utenti</h4>
<p>Visualizza e gestisci tutti gli utenti registrati sulla piattaforma.</p>
</div>
</a>
</nav>
</section>
</div>
</main>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide → topnav-v3.js (chrome) → i18n → init pagina -->
<script src="/js/api.js?v=20260730a"></script>
<script src="/js/common.js?v=20260729b"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar();
I18n.init(); if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
// ── Admin check ────────────────────────────────────────── // ── Admin check ──────────────────────────────────────────
(async function checkAdmin() { (async function checkAdmin() {
+76 -142
View File
@@ -3,103 +3,47 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gestione Organizzazioni - NIS2 Agile</title> <title>Gestione Organizzazioni · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css. Pagina in /admin/: asset con path ASSOLUTI. -->
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
via JS (tabella org/paginazione/badge) -> parita' funzionale assoluta. BI veste la chrome. <link rel="stylesheet" href="/css/v3.css?v=20260627">
Path relativi ../ perche' la pagina vive sotto public/admin/. -->
<link rel="stylesheet" href="../vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="../css/style.css?v=20260618">
<style> <style>
.entity-type-badge { /* ── Tabella admin (v3.css non include stili tabella; markup sobrio, no CDN) ── */
display: inline-flex; .table-container { width:100%; overflow-x:auto; }
padding: 3px 10px; table { width:100%; border-collapse:collapse; font-size:13px; }
border-radius: 100px; thead th { text-align:left; font-size:10.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; color:#7a8aa0; padding:10px 14px; border-bottom:1px solid var(--line); white-space:nowrap; }
font-size: 0.7rem; tbody td { padding:12px 14px; border-bottom:1px solid var(--line); vertical-align:middle; }
font-weight: 600; tbody tr:last-child td { border-bottom:0; }
text-transform: uppercase; tbody tr:hover { background:#F7FAFD; }
letter-spacing: 0.03em; td strong { font-weight:700; }
} .text-muted { color:var(--muted); }
.entity-type-badge.essential { background: var(--danger-bg); color: var(--danger); }
.entity-type-badge.important { background: var(--warning-bg); color: #a16207; } .entity-type-badge, .plan-badge { display:inline-flex; padding:2px 7px; border-radius:6px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; white-space:nowrap; }
.entity-type-badge.not_applicable { background: var(--gray-100); color: var(--gray-600); } .entity-type-badge.essential { background:#FBE9E9; color:#B23636; }
.plan-badge { .entity-type-badge.important { background:#FBF1E2; color:#9A6418; }
display: inline-flex; .entity-type-badge.not_applicable { background:#EEF2F7; color:#5b6b80; }
padding: 3px 10px; .plan-badge.free { background:#EEF2F7; color:#5b6b80; }
border-radius: 100px; .plan-badge.professional { background:#E7F0FB; color:#1F5FA8; }
font-size: 0.7rem; .plan-badge.enterprise { background:#F0E9FB; color:#7C4DD6; }
font-weight: 600;
text-transform: uppercase; .status-dot { display:inline-flex; align-items:center; gap:6px; font-size:.8125rem; }
letter-spacing: 0.03em; .status-dot::before { content:''; width:8px; height:8px; border-radius:50%; flex-shrink:0; }
} .status-dot.active::before { background:var(--c-green); }
.plan-badge.free { background: var(--gray-100); color: var(--gray-600); } .status-dot.inactive::before { background:#94A3B8; }
.plan-badge.professional { background: var(--primary-bg); color: var(--primary); }
.plan-badge.enterprise { background: #f3e8ff; color: #7c3aed; } .score-inline { display:inline-flex; align-items:center; gap:6px; font-size:.8125rem; font-weight:600; }
.status-dot { .score-inline .score-dot { width:8px; height:8px; border-radius:50%; }
display: inline-flex;
align-items: center; .pagination { display:flex; align-items:center; justify-content:space-between; padding:14px 14px 2px; border-top:1px solid var(--line); margin-top:6px; }
gap: 6px; .pagination-info { font-size:.8125rem; color:var(--muted); }
font-size: 0.8125rem; .pagination-controls { display:flex; gap:8px; }
} .btn-ghost[disabled] { opacity:.5; cursor:not-allowed; }
.status-dot::before { .btn-sm { padding:6px 11px; font-size:11.5px; }
content: '';
width: 8px; .empty-state { text-align:center; padding:60px 24px; color:var(--muted); }
height: 8px; .empty-state .ic { width:48px; height:48px; color:var(--line); margin:0 auto 16px; display:block; }
border-radius: 50%; .empty-state h4 { font-size:1rem; font-weight:700; color:var(--ink); margin:0 0 8px; }
flex-shrink: 0; .empty-state p { margin:0; }
}
.status-dot.active::before { background: var(--secondary); }
.status-dot.inactive::before { background: var(--gray-400); }
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid var(--gray-100);
background: var(--gray-50);
}
.pagination-info {
font-size: 0.8125rem;
color: var(--gray-500);
}
.pagination-controls {
display: flex;
gap: 8px;
}
.score-inline {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
font-weight: 600;
}
.score-inline .score-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.admin-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--gray-500);
margin-bottom: 20px;
}
.admin-breadcrumb a {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.admin-breadcrumb a:hover {
text-decoration: underline;
}
.admin-breadcrumb .icon {
width: 14px;
height: 14px;
color: var(--gray-400);
fill: currentColor;
}
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -116,55 +60,44 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content"> <main class="wrap">
<header class="content-header"> <nav class="crumb" aria-label="breadcrumb">
<h1 class="h2">Gestione Organizzazioni</h1> <i data-lucide="home" class="ic"></i><a href="/dashboard.html">Home</a>
<div class="content-header-actions"> <i data-lucide="chevron-right" class="ic"></i><a href="/admin/index.html">Amministrazione</a>
<span class="badge badge-danger">Super Admin</span> <i data-lucide="chevron-right" class="ic"></i><span>Organizzazioni</span>
</div> </nav>
</header> <div class="page-head">
<div>
<div class="content-body"> <h1>Gestione Organizzazioni</h1>
<!-- Breadcrumb --> <p class="sub">Tutte le organizzazioni registrate sulla piattaforma</p>
<nav class="admin-breadcrumb" aria-label="Percorso di navigazione">
<a href="../admin/index.html">Amministrazione</a>
<svg class="icon icon-sm" aria-hidden="true"><use href="../vendor/bootstrap-italia/dist/svg/sprites.svg#it-chevron-right"></use></svg>
<span aria-current="page">Organizzazioni</span>
</nav>
<!-- Table -->
<div class="card">
<div class="card-body" style="padding:0;">
<div id="orgs-container">
<div class="spinner" style="margin:60px auto;" role="status" aria-label="Caricamento in corso"></div>
</div>
</div>
</div>
</div> </div>
</main> <span class="badge-t red">Super Admin</span>
</div> </div>
<script src="../js/api.js?v=20260620"></script> <section class="card card-pad">
<script src="../js/common.js?v=20260620"></script> <div id="orgs-container">
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div>
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, </div>
riusando tutti gli helper di common.js (zero backend). --> </section>
<script src="../vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> </main>
<script>
if (window.bootstrap && bootstrap.loadFonts) { <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide → topnav-v3.js (chrome) → i18n → init pagina -->
bootstrap.loadFonts('../vendor/bootstrap-italia/dist/fonts'); <script src="/js/api.js?v=20260730a"></script>
} <script src="/js/common.js?v=20260729b"></script>
</script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="../js/common-bi.js?v=20260618"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="../js/i18n.js?v=20260620"></script> <script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar();
I18n.init(); if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
let currentPage = 1; let currentPage = 1;
@@ -205,7 +138,7 @@
// ── Caricamento ────────────────────────────────────────── // ── Caricamento ──────────────────────────────────────────
async function loadOrganizations(page) { async function loadOrganizations(page) {
const container = document.getElementById('orgs-container'); const container = document.getElementById('orgs-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;" role="status" aria-label="Caricamento in corso"></div>'; container.innerHTML = '<div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div>';
currentPage = page; currentPage = page;
try { try {
@@ -231,10 +164,11 @@
if (!orgs || orgs.length === 0) { if (!orgs || orgs.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="empty-state"> <div class="empty-state">
<svg class="icon" aria-hidden="true" style="width:48px;height:48px;fill:var(--gray-300);"><use href="../vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg> <i data-lucide="building-2" class="ic"></i>
<h4>Nessuna organizzazione trovata</h4> <h4>Nessuna organizzazione trovata</h4>
<p>Le organizzazioni registrate appariranno qui.</p> <p>Le organizzazioni registrate appariranno qui.</p>
</div>`; </div>`;
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
return; return;
} }
@@ -304,10 +238,10 @@
Visualizzati ${start}-${end} di ${total} organizzazioni Visualizzati ${start}-${end} di ${total} organizzazioni
</div> </div>
<div class="pagination-controls"> <div class="pagination-controls">
<button class="btn btn-secondary btn-sm" onclick="loadOrganizations(${page - 1})" ${page <= 1 ? 'disabled' : ''}> <button class="btn-ghost btn-sm" onclick="loadOrganizations(${page - 1})" ${page <= 1 ? 'disabled' : ''}>
&laquo; Precedente &laquo; Precedente
</button> </button>
<button class="btn btn-secondary btn-sm" onclick="loadOrganizations(${page + 1})" ${page >= totalPages ? 'disabled' : ''}> <button class="btn-ghost btn-sm" onclick="loadOrganizations(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
Successivo &raquo; Successivo &raquo;
</button> </button>
</div> </div>
+89 -169
View File
@@ -3,125 +3,54 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gestione Utenti - NIS2 Agile</title> <title>Gestione Utenti · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css. Pagina in /admin/: asset con path ASSOLUTI. -->
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
via JS (tabella utenti/azioni) -> parita' funzionale assoluta. BI veste la chrome. <link rel="stylesheet" href="/css/v3.css?v=20260627">
Path ../ perche' questa pagina vive in /admin/. -->
<link rel="stylesheet" href="../vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="../css/style.css?v=20260618">
<style> <style>
.role-badge { /* ── Tabella admin (v3.css non include stili tabella; markup sobrio, no CDN) ── */
display: inline-flex; .table-container { width:100%; overflow-x:auto; }
padding: 3px 10px; table { width:100%; border-collapse:collapse; font-size:13px; }
border-radius: 100px; thead th { text-align:left; font-size:10.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; color:#7a8aa0; padding:10px 14px; border-bottom:1px solid var(--line); white-space:nowrap; }
font-size: 0.7rem; tbody td { padding:12px 14px; border-bottom:1px solid var(--line); vertical-align:middle; }
font-weight: 600; tbody tr:last-child td { border-bottom:0; }
text-transform: uppercase; tbody tr:hover { background:#F7FAFD; }
letter-spacing: 0.03em; td strong { font-weight:700; }
} .text-muted { color:var(--muted); }
.role-badge.super_admin { background: var(--danger-bg); color: var(--danger); }
.role-badge.org_admin { background: var(--primary-bg); color: var(--primary); } .visually-hidden { position:absolute !important; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.role-badge.compliance_manager { background: var(--secondary-bg); color: #15803d; }
.role-badge.board_member { background: #f3e8ff; color: #7c3aed; } .role-badge { display:inline-flex; padding:2px 7px; border-radius:6px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; white-space:nowrap; }
.role-badge.auditor { background: var(--info-bg); color: var(--primary); } .role-badge.super_admin { background:#FBE9E9; color:#B23636; }
.role-badge.employee { background: var(--gray-100); color: var(--gray-600); } .role-badge.org_admin { background:#E7F0FB; color:#1F5FA8; }
.active-toggle { .role-badge.compliance_manager { background:#E6F4EC; color:#1E7A4E; }
position: relative; .role-badge.board_member { background:#F0E9FB; color:#7C4DD6; }
display: inline-flex; .role-badge.auditor { background:#E7F0FB; color:#1F5FA8; }
align-items: center; .role-badge.employee { background:#EEF2F7; color:#5b6b80; }
cursor: pointer;
} .active-toggle { position:relative; display:inline-flex; align-items:center; cursor:pointer; }
.active-toggle input { .active-toggle input { position:absolute; opacity:0; width:0; height:0; }
position: absolute; .toggle-slider { width:36px; height:20px; background:#CBD5E1; border-radius:10px; transition:background .15s; position:relative; }
opacity: 0; .toggle-slider::after { content:''; position:absolute; width:16px; height:16px; background:#fff; border-radius:50%; top:2px; left:2px; transition:transform .15s; box-shadow:0 1px 3px rgba(0,0,0,0.2); }
width: 0; .active-toggle input:checked + .toggle-slider { background:var(--c-green); }
height: 0; .active-toggle input:checked + .toggle-slider::after { transform:translateX(16px); }
} .active-toggle input:focus-visible + .toggle-slider { outline:2px solid var(--gold); outline-offset:2px; }
.toggle-slider { @media (prefers-reduced-motion:reduce) { .toggle-slider, .toggle-slider::after { transition:none; } }
width: 36px;
height: 20px; .org-tags { display:flex; flex-wrap:wrap; gap:4px; }
background: var(--gray-300); .org-tag { display:inline-flex; padding:2px 8px; font-size:10.5px; font-weight:600; background:#EEF2F7; color:#5b6b80; border-radius:6px; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
border-radius: 10px;
transition: background var(--transition-fast); .pagination { display:flex; align-items:center; justify-content:space-between; padding:14px 14px 2px; border-top:1px solid var(--line); margin-top:6px; }
position: relative; .pagination-info { font-size:.8125rem; color:var(--muted); }
} .pagination-controls { display:flex; gap:8px; }
.toggle-slider::after { .btn-ghost[disabled] { opacity:.5; cursor:not-allowed; }
content: ''; .btn-sm { padding:6px 11px; font-size:11.5px; }
position: absolute; .btn-ghost .ic { color:var(--c-blue); }
width: 16px;
height: 16px; .empty-state { text-align:center; padding:60px 24px; color:var(--muted); }
background: #fff; .empty-state .ic { width:48px; height:48px; color:var(--line); margin:0 auto 16px; display:block; }
border-radius: 50%; .empty-state h4 { font-size:1rem; font-weight:700; color:var(--ink); margin:0 0 8px; }
top: 2px; .empty-state p { margin:0; }
left: 2px;
transition: transform var(--transition-fast);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.active-toggle input:checked + .toggle-slider {
background: var(--secondary);
}
.active-toggle input:checked + .toggle-slider::after {
transform: translateX(16px);
}
/* Focus visibile WCAG su toggle keyboard-operabile */
.active-toggle input:focus-visible + .toggle-slider {
outline: 2px solid var(--primary, #06c);
outline-offset: 2px;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid var(--gray-100);
background: var(--gray-50);
}
.pagination-info {
font-size: 0.8125rem;
color: var(--gray-500);
}
.pagination-controls {
display: flex;
gap: 8px;
}
.org-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.org-tag {
display: inline-flex;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 500;
background: var(--gray-100);
color: var(--gray-600);
border-radius: var(--border-radius-sm);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--gray-500);
margin-bottom: 20px;
}
.admin-breadcrumb a {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.admin-breadcrumb a:hover {
text-decoration: underline;
}
.admin-breadcrumb .icon {
color: var(--gray-400);
}
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -138,55 +67,44 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content"> <main class="wrap">
<header class="content-header"> <nav class="crumb" aria-label="breadcrumb">
<h1 class="h2">Gestione Utenti</h1> <i data-lucide="home" class="ic"></i><a href="/dashboard.html">Home</a>
<div class="content-header-actions"> <i data-lucide="chevron-right" class="ic"></i><a href="/admin/index.html">Amministrazione</a>
<span class="badge badge-danger">Super Admin</span> <i data-lucide="chevron-right" class="ic"></i><span>Utenti</span>
</div> </nav>
</header> <div class="page-head">
<div>
<div class="content-body"> <h1>Gestione Utenti</h1>
<!-- Breadcrumb --> <p class="sub">Tutti gli utenti registrati sulla piattaforma</p>
<nav class="admin-breadcrumb" aria-label="Percorso di navigazione">
<a href="../admin/index.html">Amministrazione</a>
<svg class="icon icon-sm" aria-hidden="true"><use href="../vendor/bootstrap-italia/dist/svg/sprites.svg#it-chevron-right"></use></svg>
<span aria-current="page">Utenti</span>
</nav>
<!-- Table -->
<div class="card">
<div class="card-body" style="padding:0;">
<div id="users-container" aria-live="polite">
<div class="spinner" style="margin:60px auto;" role="status" aria-label="Caricamento utenti"></div>
</div>
</div>
</div>
</div> </div>
</main> <span class="badge-t red">Super Admin</span>
</div> </div>
<script src="../js/api.js?v=20260620"></script> <section class="card card-pad">
<script src="../js/common.js?v=20260620"></script> <div id="users-container" aria-live="polite">
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <div class="v3-loading"><span class="v3-spin"></span>Caricamento utenti…</div>
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, </div>
riusando tutti gli helper di common.js (zero backend). --> </section>
<script src="../vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> </main>
<script>
if (window.bootstrap && bootstrap.loadFonts) { <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide → topnav-v3.js (chrome) → i18n → init pagina -->
bootstrap.loadFonts('../vendor/bootstrap-italia/dist/fonts'); <script src="/js/api.js?v=20260730a"></script>
} <script src="/js/common.js?v=20260729b"></script>
</script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="../js/common-bi.js?v=20260618"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="../js/i18n.js?v=20260620"></script> <script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar();
I18n.init(); if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
let currentPage = 1; let currentPage = 1;
@@ -196,12 +114,12 @@
const me = await api.getMe(); const me = await api.getMe();
if (!me.success || !me.data || me.data.role !== 'super_admin') { if (!me.success || !me.data || me.data.role !== 'super_admin') {
showNotification('Accesso non autorizzato.', 'error'); showNotification('Accesso non autorizzato.', 'error');
setTimeout(() => { window.location.href = '../dashboard.html'; }, 1500); setTimeout(() => { window.location.href = '/dashboard.html'; }, 1500);
return; return;
} }
loadUsers(1); loadUsers(1);
} catch (e) { } catch (e) {
window.location.href = '../dashboard.html'; window.location.href = '/dashboard.html';
} }
})(); })();
@@ -218,7 +136,7 @@
// ── Caricamento ────────────────────────────────────────── // ── Caricamento ──────────────────────────────────────────
async function loadUsers(page) { async function loadUsers(page) {
const container = document.getElementById('users-container'); const container = document.getElementById('users-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;" role="status" aria-label="Caricamento utenti"></div>'; container.innerHTML = '<div class="v3-loading"><span class="v3-spin"></span>Caricamento utenti…</div>';
currentPage = page; currentPage = page;
try { try {
@@ -244,10 +162,11 @@
if (!users || users.length === 0) { if (!users || users.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="empty-state"> <div class="empty-state">
<svg class="icon icon-lg" aria-hidden="true" style="margin:0 auto;"><use href="../vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg> <i data-lucide="users" class="ic"></i>
<h4>Nessun utente trovato</h4> <h4>Nessun utente trovato</h4>
<p>Gli utenti registrati appariranno qui.</p> <p>Gli utenti registrati appariranno qui.</p>
</div>`; </div>`;
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
return; return;
} }
@@ -306,8 +225,8 @@
<td>${formatDate(user.created_at)}</td> <td>${formatDate(user.created_at)}</td>
<td> <td>
${role !== 'super_admin' && isActive ${role !== 'super_admin' && isActive
? `<button class="btn btn-secondary btn-sm" onclick="impersonateUser(${user.id}, '${escapeHtml(user.email)}')" title="Entra come questo utente per 1 ora"> ? `<button class="btn-ghost btn-sm" onclick="impersonateUser(${user.id}, '${escapeHtml(user.email)}')" title="Entra come questo utente per 1 ora">
<svg class="icon icon-sm" aria-hidden="true" style="vertical-align:-2px"><use href="../vendor/bootstrap-italia/dist/svg/sprites.svg#it-exchange-circle"></use></svg> <i data-lucide="repeat" class="ic"></i>
Impersonate Impersonate
</button>` </button>`
: '<span class="text-muted">-</span>'} : '<span class="text-muted">-</span>'}
@@ -332,10 +251,10 @@
Visualizzati ${start}-${end} di ${total} utenti Visualizzati ${start}-${end} di ${total} utenti
</div> </div>
<div class="pagination-controls"> <div class="pagination-controls">
<button class="btn btn-secondary btn-sm" onclick="loadUsers(${page - 1})" ${page <= 1 ? 'disabled' : ''}> <button class="btn-ghost btn-sm" onclick="loadUsers(${page - 1})" ${page <= 1 ? 'disabled' : ''}>
&laquo; Precedente &laquo; Precedente
</button> </button>
<button class="btn btn-secondary btn-sm" onclick="loadUsers(${page + 1})" ${page >= totalPages ? 'disabled' : ''}> <button class="btn-ghost btn-sm" onclick="loadUsers(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
Successivo &raquo; Successivo &raquo;
</button> </button>
</div> </div>
@@ -351,6 +270,7 @@
} }
container.innerHTML = html; container.innerHTML = html;
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
} }
// ── Toggle Attivo/Inattivo ─────────────────────────────── // ── Toggle Attivo/Inattivo ───────────────────────────────
@@ -388,7 +308,7 @@
sessionStorage.setItem('nis2_impersonate_target_email', email); sessionStorage.setItem('nis2_impersonate_target_email', email);
// Sostituisce token con quello impersonate (no refresh per default) // Sostituisce token con quello impersonate (no refresh per default)
api.setTokens(res.data.access_token, ''); api.setTokens(res.data.access_token, '');
window.location.href = '../dashboard.html'; window.location.href = '/dashboard.html';
} else { } else {
showNotification(res.message || 'Impersonate fallito.', 'error'); showNotification(res.message || 'Impersonate fallito.', 'error');
} }
+59 -25
View File
@@ -3,11 +3,39 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Architettura - NIS2 Agile</title> <title>Architettura · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN), PRIMA di style.css cosi' il design system esistente resta autorevole --> <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="css/style.css?v=20260618"> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<style> <style>
/* ── V3 compat bridge: token del vecchio style.css → token v3.css ──
Mantiene funzionanti gli stili inline e la page-CSS qui sotto senza
ricaricare style.css (rimosso col V3). Nessun CDN. */
:root{
--primary:#1F5FA8; --primary-light:#4a9af5; --primary-dark:#1557b0; --primary-bg:#E7F0FB;
--secondary:#1E8A5A; --secondary-light:#5cb876; --secondary-bg:#E6F4EC;
--warning:#D98324; --warning-light:#fdd663; --warning-bg:#FBF1E2;
--danger:#C62828; --danger-light:#f28b82; --danger-bg:#FBE9E9;
--info:#1F5FA8; --info-bg:#E7F0FB;
--gray-50:#f8fafc; --gray-100:#f1f5f9; --gray-200:#e2e8f0; --gray-300:#cbd5e1;
--gray-400:#94a3b8; --gray-500:#64748b; --gray-600:#475569; --gray-700:#334155;
--gray-800:#1e293b; --gray-900:#0f172a;
--card-bg:#fff; --content-bg:var(--bg);
--card-shadow:var(--shadow); --card-shadow-hover:0 4px 12px rgba(0,0,0,.1),0 2px 4px rgba(0,0,0,.06);
--border-radius:8px; --border-radius-lg:12px; --border-radius-sm:4px;
--font-mono:'SF Mono','Fira Code','Consolas',monospace;
--transition-fast:150ms ease; --transition:250ms ease;
}
/* Card di base (ex style.css) per le sezioni architettura */
.arch-section .card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden}
.arch-section .card-header{padding:16px 20px;border-bottom:1px solid var(--line)}
.arch-section .card-header h3{margin:0;font-size:15px;font-weight:700}
.arch-section .card-body{padding:20px}
.badge{display:inline-flex;align-items:center;font-size:11px;font-weight:700;padding:3px 9px;border-radius:6px;background:#EEF2F7;color:#5b6b80}
.badge-danger{background:var(--danger-bg);color:var(--danger)}
.badge-warning{background:var(--warning-bg);color:#9A6418}
.badge-neutral{background:#EEF2F7;color:#5b6b80}
/* ── Architecture Page Styles ────────────────────────────────── */ /* ── Architecture Page Styles ────────────────────────────────── */
.arch-section { .arch-section {
@@ -634,18 +662,21 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<!-- Sidebar --> <div id="topnav"></div>
<aside class="sidebar" id="sidebar"></aside>
<!-- Main Content --> <main class="wrap">
<main class="main-content"> <nav class="crumb" aria-label="breadcrumb">
<header class="content-header"> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<h2>Architettura del Sistema</h2> <i data-lucide="chevron-right" class="ic"></i><span>Architettura</span>
<div class="content-header-actions"> </nav>
<span class="badge badge-neutral">v1.0</span> <div class="page-head">
</div> <div>
</header> <h1>Architettura del Sistema</h1>
<p class="sub">Stack tecnologico, modello dati e moduli della piattaforma</p>
</div>
<span class="badge-t blue">v1.0</span>
</div>
<div class="content-body"> <div class="content-body">
@@ -1087,19 +1118,22 @@
</div> </div>
</div> </div>
</main> </main>
</div>
<script src="js/api.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/api.js?v=20260730a"></script>
<script src="/js/common-bi.js?v=20260631"></script> <script src="/js/common.js?v=20260729b"></script>
<script src="js/i18n.js?v=20260631"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="js/help.js?v=20260618p2"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar(); // = loadTopnavV3()
I18n.init(); if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
</script> </script>
</body> </body>
</html> </html>
+326 -184
View File
@@ -3,12 +3,10 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gap Analysis - NIS2 Agile</title> <title>GAP Analysis · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css. Self-host only: no external font/script hosts. -->
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
via JS (wizard/form/gauge) -> parita' funzionale assoluta. BI veste la chrome. --> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC"> <meta name="theme-color" content="#0066CC">
@@ -22,158 +20,273 @@
<meta name="application-name" content="NIS2 Agile"> <meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script> <script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end --> <!-- PWA:end -->
<style>
/* ── GAP Analysis V3 — solo widget NON coperti da v3.css (token condivisi). ──
Fonte visiva: docs/mockup Ui/assessment.html (.ga-intro, .stat KPI, .det/.crow,
.gbadge). Le viste reali (wizard domande, gauge, categorie) sono renderizzate
dal JS della pagina → qui ne forniamo lo stile minimo (no CDN, no Bootstrap). */
.ga-intro{display:flex;gap:12px;align-items:flex-start;background:#EEF4FB;border:1px solid #D5E3F2;color:#2C4A6B;border-radius:11px;padding:14px 16px;font-size:13px;margin-bottom:20px}
.ga-intro .ic{width:18px;height:18px;flex:0 0 auto;color:var(--c-blue);margin-top:1px}
.ga-kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:20px}
.center-card{text-align:center;padding:40px 24px}
.center-card h3{font-size:18px;font-weight:800;margin:0 0 8px}
.center-card p{color:var(--muted);margin:0 auto 22px;max-width:560px;font-size:13.5px}
.btn-lg{padding:12px 20px;font-size:14px;border-radius:10px}
/* tabella assessment esistenti */
.v3-table{width:100%;border-collapse:collapse;font-size:13px}
.v3-table th{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#8093A8;text-align:left;padding:9px 12px;border-bottom:1px solid var(--line)}
.v3-table td{padding:11px 12px;border-top:1px solid var(--line)}
.v3-table tr:first-child td{border-top:0}
/* wizard: indicatore step */
.steps{display:flex;align-items:center;flex-wrap:wrap;gap:4px 0;margin-bottom:18px}
.step{display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:9px;cursor:pointer;color:var(--muted)}
.step:hover{background:#F3F7FC}
.step .step-number{width:24px;height:24px;border-radius:50%;background:#E7ECF2;color:#5b6b80;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex:0 0 auto}
.step .step-label{font-size:12.5px;font-weight:600;white-space:nowrap}
.step.active{color:var(--ink)}
.step.active .step-number{background:var(--c-blue);color:#fff}
.step.completed{color:var(--c-green)}
.step.completed .step-number{background:var(--c-green);color:#fff}
.step-connector{flex:1;min-width:14px;height:2px;background:var(--line);margin:0 2px}
/* progress bar avanzamento risposte */
.ga-prog-wrap{margin-bottom:16px}
.ga-prog-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.ga-prog-lab{font-size:11.5px;font-weight:700;color:#7a8aa0;text-transform:uppercase;letter-spacing:.04em}
.ga-prog-pct{font-size:12.5px;font-weight:800;color:var(--c-blue)}
.ga-prog-track{background:#EAF0F6;border-radius:99px;height:8px;overflow:hidden}
.ga-prog-fill{height:100%;background:var(--c-blue);border-radius:99px;transition:width .3s ease;width:0}
.ga-prog-sub{font-size:11px;color:var(--muted);margin-top:4px}
/* card domanda */
.qcard-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid var(--line)}
.qcard-head h3{margin:0;font-size:15px;font-weight:700}
.qcard-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:14px 20px;border-top:1px solid var(--line);flex-wrap:wrap}
/* domanda: testo + tag */
.q-text{font-size:15px;font-weight:600;color:var(--ink);margin:0 0 4px}
.q-guidance{color:var(--muted);font-size:13px;margin:0 0 8px}
.q-tag{display:inline-block;font-size:10.5px;font-weight:700;padding:3px 8px;border-radius:6px;background:#EEF2F7;color:#5b6b80;margin-top:6px;margin-right:4px}
/* form */
.form-group{margin-bottom:18px}
.form-label{display:block;font-size:11.5px;font-weight:700;color:#7a8aa0;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px}
.form-radio-group{display:flex;flex-wrap:wrap;gap:9px}
.form-radio{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:9px;padding:9px 13px;font-size:13px;font-weight:600;cursor:pointer;background:#fff}
.form-radio:hover{border-color:#9FBEDD}
.form-radio.selected{border-color:var(--c-blue);background:#EEF4FB;box-shadow:0 0 0 2px rgba(31,95,168,.15)}
.form-radio input{accent-color:var(--c-blue);cursor:pointer}
.form-range{width:100%;accent-color:var(--c-blue);cursor:pointer}
.form-range-labels{display:flex;justify-content:space-between;align-items:center;font-size:11.5px;color:var(--muted);margin-top:6px}
.form-range-labels #maturity-value{font-size:14px;font-weight:800;color:var(--c-blue)}
.form-textarea,.form-input{width:100%;border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit;font-size:13.5px;color:var(--ink);background:#fff;outline:none}
.form-textarea:focus,.form-input:focus{border-color:var(--c-blue);box-shadow:0 0 0 3px rgba(31,95,168,.12)}
.form-textarea{resize:vertical;min-height:70px}
/* badge generici (renderizzati dal JS) */
.badge{display:inline-block;font-size:10.5px;font-weight:700;padding:3px 9px;border-radius:999px;white-space:nowrap}
.badge-neutral{background:#EEF2F7;color:#5b6b80}
.badge-success{background:#E6F4EC;color:#1E7A4E}
.badge-warning{background:#FBF1E2;color:#9A6418}
.badge-info{background:#E7F0FB;color:#1F5FA8}
/* bottoni stato (JS riusa .btn-success per "Completa") */
.btn-success{display:inline-flex;align-items:center;gap:7px;background:var(--c-green);color:#fff;border:0;border-radius:8px;padding:8px 13px;font:inherit;font-weight:600;font-size:12.5px;cursor:pointer}
.btn-success:hover{background:#19764C}
.btn-secondary{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 13px;font:inherit;font-weight:600;font-size:12.5px;color:var(--ink);cursor:pointer}
.btn-secondary:hover{background:#F5F8FC}
.btn-secondary:disabled,.btn-primary:disabled,.btn-success:disabled{opacity:.5;cursor:not-allowed}
.btn-sm{padding:6px 11px;font-size:12px}
.btn-outline{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--c-blue);background:#fff;border-radius:8px;padding:7px 12px;font:inherit;font-weight:600;font-size:12.5px;color:var(--c-blue);cursor:pointer}
.btn-outline:hover{background:#EEF4FB}
/* risultati: gauge (renderizzato da common.renderScoreGauge) */
.score-gauge{position:relative;width:200px;height:200px;margin:0 auto}
.score-gauge svg{transform:rotate(-90deg)}
.score-gauge-circle-bg{fill:none;stroke:#EAF0F6;stroke-width:12}
.score-gauge-circle{fill:none;stroke-width:12;stroke-linecap:round;stroke:var(--score-color,var(--c-blue));transition:stroke-dashoffset 1s ease,stroke .5s ease}
.score-gauge-value{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center}
.score-gauge-number{font-size:2.6rem;font-weight:800;color:var(--ink);line-height:1}
.score-gauge-label{font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-top:4px}
.score-critical{--score-color:var(--c-red)}.score-low{--score-color:#f97316}.score-medium{--score-color:var(--c-amber)}.score-good{--score-color:#84cc16}.score-excellent{--score-color:var(--c-green)}
/* risultati: punteggio per categoria */
.category-score{margin-bottom:14px}
.category-score:last-child{margin-bottom:0}
.category-score-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.category-score-name{font-size:13px;font-weight:600;color:var(--ink)}
.category-score-value{font-size:13px;font-weight:800}
.progress{background:#EAF0F6;border-radius:99px;height:8px;overflow:hidden}
.progress-bar{height:100%;border-radius:99px;background:var(--c-blue);transition:width .4s ease}
.progress-bar.success{background:var(--c-green)}.progress-bar.warning{background:var(--c-amber)}.progress-bar.danger{background:var(--c-red)}
/* spinner inline (riusato dal JS) */
.spinner-lg{width:36px;height:36px;border:3px solid var(--line);border-top-color:var(--c-blue);border-radius:50%;animation:v3spin .7s linear infinite}
.spinner{width:16px;height:16px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;animation:v3spin .7s linear infinite;display:inline-block;vertical-align:middle}
.mb-24{margin-bottom:24px}
.text-muted{color:var(--muted)}
.ai-block h4{margin:0 0 8px;font-size:14px;font-weight:700}
.ai-block ul,.ai-block ol{margin:0;padding-left:20px;color:var(--muted)}
@media (max-width:900px){.ga-kpis{grid-template-columns:1fr}}
@media (prefers-reduced-motion:reduce){.spinner-lg,.spinner{animation:none}}
</style>
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<!-- Sidebar --> <div id="topnav"></div>
<aside class="sidebar" id="sidebar"></aside>
<!-- Main Content --> <main class="wrap">
<main class="main-content"> <nav class="crumb" aria-label="breadcrumb">
<header class="content-header"> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<h1 class="h2" data-i18n="assessment.title">Gap Analysis NIS2</h1> <i data-lucide="chevron-right" class="ic"></i><span data-i18n="assessment.title">Gap Analysis NIS2</span>
<div class="content-header-actions"> </nav>
<button class="btn btn-outline btn-sm" id="btn-ai-analyze" style="display:none;" onclick="aiAnalyze()"> <div class="page-head">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg> <div>
Analisi AI <h1 data-i18n="assessment.title">Gap Analysis NIS2</h1>
</button> <p class="sub">Scostamento tra lo stato attuale e i requisiti della Direttiva (UE) 2022/2555</p>
</div>
<div class="content-header-actions">
<button class="btn-outline" id="btn-ai-analyze" style="display:none;" onclick="aiAnalyze()">
<i data-lucide="sparkles" class="ic"></i> Analisi AI
</button>
</div>
</div>
<div class="ga-intro">
<i data-lucide="info" class="ic"></i>
<div>L'assessment confronta il livello di conformità <b>effettivo</b> della tua organizzazione con quello <b>atteso</b> dai requisiti NIS2, evidenziando i gap su cui intervenire. I gap rilevati possono essere convertiti automaticamente in non conformità (NCR).</div>
</div>
<!-- KPI sintetici (dati reali, popolati dal JS) -->
<div class="ga-kpis" id="ga-kpis" style="display:none;"></div>
<!-- Assessment Selection / Create -->
<section class="card mb-24" id="assessment-start">
<div class="center-card">
<h3>Assessment di Conformità NIS2</h3>
<p>Valuta il livello di conformità della tua organizzazione rispetto ai requisiti della direttiva NIS2.</p>
<div id="existing-assessments" class="mb-24" style="display:none; text-align:left;"></div>
<button class="btn-primary btn-lg" id="btn-new-assessment" onclick="createNewAssessment()">
<i data-lucide="plus" class="ic"></i> Nuovo Assessment
</button>
</div>
</section>
<!-- Wizard Container -->
<div id="assessment-wizard" style="display:none;">
<!-- Steps Indicator -->
<div class="steps" id="steps-indicator"></div>
<!-- Progress Bar -->
<div class="ga-prog-wrap">
<div class="ga-prog-top">
<span class="ga-prog-lab">Avanzamento risposta domande</span>
<span class="ga-prog-pct" id="progress-pct">0%</span>
</div> </div>
</header> <div class="ga-prog-track">
<div id="progress-bar-fill" class="ga-prog-fill"></div>
<div class="content-body">
<!-- Assessment Selection / Create -->
<div id="assessment-start" class="card mb-24">
<div class="card-body text-center" style="padding:48px;">
<h3 style="font-size:1.25rem; color:var(--gray-900); margin-bottom:8px;">Assessment di Conformita' NIS2</h3>
<p class="text-muted mb-24">Valuta il livello di conformita' della tua organizzazione rispetto ai requisiti della direttiva NIS2.</p>
<div id="existing-assessments" class="mb-24" style="display:none;"></div>
<button class="btn btn-primary btn-lg" id="btn-new-assessment" onclick="createNewAssessment()">
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Nuovo Assessment
</button>
</div>
</div> </div>
<div class="ga-prog-sub">
<!-- Wizard Container --> <span id="progress-answered">0</span> di <span id="progress-total">0</span> domande con risposta
<div id="assessment-wizard" style="display:none;">
<!-- Steps Indicator -->
<div class="steps" id="steps-indicator"></div>
<!-- Progress Bar -->
<div style="margin-bottom:16px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<span style="font-size:0.78rem; font-weight:600; color:var(--gray-600);">Avanzamento risposta domande</span>
<span style="font-size:0.78rem; font-weight:700; color:var(--primary);" id="progress-pct">0%</span>
</div>
<div style="background:var(--gray-200); border-radius:99px; height:8px; overflow:hidden;">
<div id="progress-bar-fill" style="height:100%; background:var(--primary); border-radius:99px; transition:width 0.3s ease; width:0%;"></div>
</div>
<div style="font-size:0.7rem; color:var(--gray-400); margin-top:4px;">
<span id="progress-answered">0</span> di <span id="progress-total">0</span> domande con risposta
</div>
</div>
<!-- Question Card -->
<div class="card mb-24">
<div class="card-header">
<h3 id="category-title">Caricamento...</h3>
<span class="badge badge-neutral" id="question-counter">0/0</span>
</div>
<div class="card-body" id="question-area">
<div class="spinner-lg" style="margin:40px auto;"></div>
</div>
<div class="card-footer">
<div style="display:flex; justify-content:space-between; align-items:center;">
<button class="btn btn-secondary" id="btn-prev" onclick="prevQuestion()" disabled>
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
Precedente
</button>
<button class="btn btn-ghost btn-sm" onclick="saveCurrentResponse()">
Salva Progresso
</button>
<button class="btn btn-primary" id="btn-next" onclick="nextQuestion()">
Successiva
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</button>
</div>
</div>
</div>
</div>
<!-- Results Container -->
<div id="assessment-results" style="display:none;">
<div class="card mb-24">
<div class="card-header">
<h3>Risultati Assessment</h3>
<button class="btn btn-outline btn-sm" onclick="aiAnalyze()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
Analisi AI
</button>
</div>
<div class="card-body">
<!-- Overall Score -->
<div class="text-center mb-32" id="results-gauge"></div>
<!-- Category Scores -->
<h4 style="margin-bottom:16px; color:var(--gray-900);">Punteggio per Categoria</h4>
<div id="category-scores"></div>
</div>
</div>
<!-- AI Analysis result -->
<div class="card mb-24" id="ai-analysis-card" style="display:none;">
<div class="card-header">
<h3>Analisi AI</h3>
<span class="badge badge-info">Intelligenza Artificiale</span>
</div>
<div class="card-body" id="ai-analysis-content"></div>
</div>
<!-- Generate NCR from gaps -->
<div class="card mb-24" id="ncr-generation-card">
<div class="card-header">
<h3>Non Conformita'</h3>
</div>
<div class="card-body" style="text-align:center; padding:32px;">
<p style="color:var(--gray-600); margin-bottom:16px; font-size:0.875rem;">
Genera automaticamente le non conformita' (NCR) dai gap identificati nell'assessment.
I controlli con stato "Non Implementato" saranno classificati come <strong>major</strong>,
quelli "Parzialmente Implementato" come <strong>minor</strong>.
</p>
<button class="btn btn-primary" id="btn-generate-ncr" onclick="generateNCRs()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/></svg>
Genera Non Conformita' dai Gap
</button>
<div id="ncr-generation-result" style="display:none; margin-top:16px;"></div>
</div>
</div>
</div> </div>
</div> </div>
</main>
</div>
<script src="/js/api.js?v=20260631"></script> <!-- Question Card -->
<script src="/js/common.js?v=20260631"></script> <section class="card mb-24">
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <div class="qcard-head">
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, <h3 id="category-title">Caricamento...</h3>
riusando tutti gli helper di common.js (zero backend). --> <span class="badge badge-neutral" id="question-counter">0/0</span>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> </div>
<script> <div class="card-pad" id="question-area">
if (window.bootstrap && bootstrap.loadFonts) { <div class="spinner-lg" style="margin:40px auto;"></div>
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); </div>
} <div class="qcard-foot">
</script> <button class="btn-secondary" id="btn-prev" onclick="prevQuestion()" disabled>
<script src="/js/common-bi.js?v=20260631"></script> <i data-lucide="chevron-left" class="ic"></i> Precedente
<script src="/js/i18n.js?v=20260631"></script> </button>
<script src="/js/help.js?v=20260618p2"></script> <button class="btn-ghost btn-sm" onclick="saveCurrentResponse()">
Salva Progresso
</button>
<button class="btn-primary" id="btn-next" onclick="nextQuestion()">
Successiva <i data-lucide="chevron-right" class="ic"></i>
</button>
</div>
</section>
</div>
<!-- Results Container -->
<div id="assessment-results" style="display:none;">
<section class="card mb-24">
<div class="qcard-head">
<h3>Risultati Assessment</h3>
<button class="btn-outline btn-sm" onclick="aiAnalyze()">
<i data-lucide="sparkles" class="ic"></i> Analisi AI
</button>
</div>
<div class="card-pad">
<!-- Overall Score -->
<div style="text-align:center;" class="mb-24" id="results-gauge"></div>
<!-- Category Scores -->
<h4 style="margin:0 0 16px; font-size:15px; font-weight:700;">Punteggio per Categoria</h4>
<div id="category-scores"></div>
</div>
</section>
<!-- AI Analysis result -->
<section class="card mb-24" id="ai-analysis-card" style="display:none;">
<div class="qcard-head">
<h3>Analisi AI</h3>
<span class="badge badge-info">Intelligenza Artificiale</span>
</div>
<div class="card-pad" id="ai-analysis-content"></div>
</section>
<!-- Generate NCR from gaps -->
<section class="card mb-24" id="ncr-generation-card">
<div class="qcard-head">
<h3>Non Conformità</h3>
</div>
<div class="center-card">
<p>
Genera automaticamente le non conformità (NCR) dai gap identificati nell'assessment.
I controlli con stato "Non Implementato" saranno classificati come <strong>major</strong>,
quelli "Parzialmente Implementato" come <strong>minor</strong>.
</p>
<button class="btn-primary" id="btn-generate-ncr" onclick="generateNCRs()">
<i data-lucide="file-plus" class="ic"></i> Genera Non Conformità dai Gap
</button>
<div id="ncr-generation-result" style="display:none; margin-top:16px; text-align:left;"></div>
</div>
</section>
</div>
</main>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help. La logica
pagina (wizard GAP) resta inline: usa gli stessi id/funzioni di prima. -->
<script src="/js/api.js?v=20260730a"></script>
<script src="/js/common.js?v=20260729b"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init(); // ── Init chrome + i18n + help ────────────────────────────
HelpSystem.init(); loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
function refreshIcons() { if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} } }
// ── State ──────────────────────────────────────────────── // ── State ────────────────────────────────────────────────
let currentAssessmentId = null; let currentAssessmentId = null;
@@ -183,7 +296,7 @@
let currentQuestionIdx = 0; let currentQuestionIdx = 0;
let responses = {}; // questionId -> { answer, maturity, notes, evidence } let responses = {}; // questionId -> { answer, maturity, notes, evidence }
// ── Init ───────────────────────────────────────────────── // ── Init (dati reali) ────────────────────────────────────
loadAssessments(); loadAssessments();
async function loadAssessments() { async function loadAssessments() {
@@ -193,8 +306,8 @@
const container = document.getElementById('existing-assessments'); const container = document.getElementById('existing-assessments');
container.style.display = 'block'; container.style.display = 'block';
let html = '<h4 style="margin-bottom:12px; color:var(--gray-700);">Assessment Esistenti</h4>'; let html = '<h4 style="margin:0 0 12px; font-size:13px; font-weight:700; color:#7a8aa0; text-transform:uppercase; letter-spacing:.04em;">Assessment Esistenti</h4>';
html += '<div class="table-container"><table><thead><tr><th>Data</th><th>Stato</th><th>Punteggio</th><th>Azioni</th></tr></thead><tbody>'; html += '<div class="card card-pad" style="padding:6px 14px;"><table class="v3-table"><thead><tr><th>Data</th><th>Stato</th><th>Punteggio</th><th>Azioni</th></tr></thead><tbody>';
result.data.forEach(a => { result.data.forEach(a => {
const statusBadge = a.status === 'completed' const statusBadge = a.status === 'completed'
@@ -204,11 +317,11 @@
<tr> <tr>
<td>${formatDate(a.created_at)}</td> <td>${formatDate(a.created_at)}</td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td>${a.overall_score != null ? Math.round(a.overall_score) + '%' : '-'}</td> <td>${a.overall_score != null ? Math.round(a.overall_score) + '%' : '—'}</td>
<td> <td>
${a.status === 'completed' ${a.status === 'completed'
? `<button class="btn btn-sm btn-ghost" onclick="viewResults('${a.id}')">Risultati</button>` ? `<button class="btn-ghost btn-sm" onclick="viewResults('${a.id}')">Risultati</button>`
: `<button class="btn btn-sm btn-primary" onclick="resumeAssessment('${a.id}')">Riprendi</button>` : `<button class="btn-primary btn-sm" onclick="resumeAssessment('${a.id}')">Riprendi</button>`
} }
</td> </td>
</tr>`; </tr>`;
@@ -216,12 +329,38 @@
html += '</tbody></table></div>'; html += '</tbody></table></div>';
container.innerHTML = html; container.innerHTML = html;
refreshIcons();
// KPI sintetici (dati reali)
renderKpis(result.data);
} }
} catch (e) { } catch (e) {
// Silenzioso // Silenzioso
} }
} }
// ── KPI sintetici dai dati reali assessment ──────────────
function renderKpis(list) {
const wrap = document.getElementById('ga-kpis');
if (!wrap || !Array.isArray(list) || !list.length) return;
const total = list.length;
const completed = list.filter(a => a.status === 'completed').length;
const inProgress = total - completed;
const scored = list.filter(a => a.overall_score != null);
const avg = scored.length ? Math.round(scored.reduce((s, a) => s + Number(a.overall_score || 0), 0) / scored.length) : null;
const cards = [
['blue', 'clipboard-list', 'Assessment', total, 'totali'],
['amber', 'loader', 'In corso', inProgress, completed + ' completati'],
['green', 'target', 'Conformità media', avg != null ? (avg + '%') : '—', scored.length ? ('su ' + scored.length + ' completati') : 'nessun punteggio']
];
wrap.innerHTML = cards.map(c =>
'<div class="card card-pad stat ' + c[0] + '"><div class="ibox"><i data-lucide="' + c[1] + '" class="ic"></i></div>' +
'<div style="flex:1;min-width:0"><div class="lbl">' + c[2] + '</div><div class="val">' + c[3] + '</div><div class="sub">' + c[4] + '</div></div></div>'
).join('');
wrap.style.display = 'grid';
refreshIcons();
}
async function createNewAssessment() { async function createNewAssessment() {
const btn = document.getElementById('btn-new-assessment'); const btn = document.getElementById('btn-new-assessment');
btn.disabled = true; btn.disabled = true;
@@ -239,7 +378,8 @@
showNotification('Errore di connessione.', 'error'); showNotification('Errore di connessione.', 'error');
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg> Nuovo Assessment`; btn.innerHTML = `<i data-lucide="plus" class="ic"></i> Nuovo Assessment`;
refreshIcons();
} }
} }
@@ -374,16 +514,14 @@
let html = ` let html = `
<div style="margin-bottom:20px;"> <div style="margin-bottom:20px;">
<p style="font-size:1rem; font-weight:600; color:var(--gray-900); margin-bottom:4px;"> <p class="q-text">${escapeHtml(q.question_text || q.text || q.title || '')}</p>
${escapeHtml(q.question_text || q.text || q.title || '')} ${q.guidance_it ? `<p class="q-guidance">${escapeHtml(q.guidance_it)}</p>` : ''}
</p> ${q.nis2_article ? `<span class="q-tag">Art. ${escapeHtml(q.nis2_article)}</span>` : ''}
${q.guidance_it ? `<p class="text-muted" style="font-size:0.8125rem;">${escapeHtml(q.guidance_it)}</p>` : ''} ${q.iso27001_control ? `<span class="q-tag">ISO ${escapeHtml(q.iso27001_control)}</span>` : ''}
${q.nis2_article ? `<span class="tag mt-8">Art. ${escapeHtml(q.nis2_article)}</span>` : ''}
${q.iso27001_control ? `<span class="tag mt-8" style="margin-left:4px;">ISO ${escapeHtml(q.iso27001_control)}</span>` : ''}
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Livello di Conformita'</label> <label class="form-label">Livello di Conformità</label>
<div class="form-radio-group"> <div class="form-radio-group">
${answers.map(a => ` ${answers.map(a => `
<label class="form-radio ${r.answer === a.value ? 'selected' : ''}"> <label class="form-radio ${r.answer === a.value ? 'selected' : ''}">
@@ -396,7 +534,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Livello di Maturita' (0-5)</label> <label class="form-label">Livello di Maturità (0-5)</label>
<input type="range" class="form-range" min="0" max="5" step="1" <input type="range" class="form-range" min="0" max="5" step="1"
value="${r.maturity != null ? r.maturity : 0}" id="maturity-slider" value="${r.maturity != null ? r.maturity : 0}" id="maturity-slider"
oninput="document.getElementById('maturity-value').textContent = this.value"> oninput="document.getElementById('maturity-value').textContent = this.value">
@@ -413,7 +551,7 @@
placeholder="Aggiungi eventuali note o osservazioni...">${escapeHtml(r.notes || '')}</textarea> placeholder="Aggiungi eventuali note o osservazioni...">${escapeHtml(r.notes || '')}</textarea>
</div> </div>
<div class="form-group"> <div class="form-group" style="margin-bottom:0;">
<label class="form-label">Descrizione Evidenza</label> <label class="form-label">Descrizione Evidenza</label>
<input type="text" class="form-input" id="question-evidence" <input type="text" class="form-input" id="question-evidence"
placeholder="Documenti, procedure, strumenti a supporto..." placeholder="Documenti, procedure, strumenti a supporto..."
@@ -430,12 +568,13 @@
const isLast = currentCategoryIdx === categories.length - 1 && currentQuestionIdx === cat.questions.length - 1; const isLast = currentCategoryIdx === categories.length - 1 && currentQuestionIdx === cat.questions.length - 1;
const nextBtn = document.getElementById('btn-next'); const nextBtn = document.getElementById('btn-next');
if (isLast) { if (isLast) {
nextBtn.innerHTML = 'Completa Assessment'; nextBtn.innerHTML = '<i data-lucide="check" class="ic"></i> Completa Assessment';
nextBtn.className = 'btn btn-success'; nextBtn.className = 'btn-success';
} else { } else {
nextBtn.innerHTML = 'Successiva <svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>'; nextBtn.innerHTML = 'Successiva <i data-lucide="chevron-right" class="ic"></i>';
nextBtn.className = 'btn btn-primary'; nextBtn.className = 'btn-primary';
} }
refreshIcons();
} }
function onAnswerChange(radio) { function onAnswerChange(radio) {
@@ -477,7 +616,7 @@
const r = responses[q.id]; const r = responses[q.id];
if (!r || !r.answer) { if (!r || !r.answer) {
showNotification('Seleziona un livello di conformita\' prima di salvare.', 'warning'); showNotification('Seleziona un livello di conformità prima di salvare.', 'warning');
return; return;
} }
@@ -570,6 +709,7 @@
document.getElementById('assessment-wizard').style.display = 'none'; document.getElementById('assessment-wizard').style.display = 'none';
document.getElementById('assessment-results').style.display = 'block'; document.getElementById('assessment-results').style.display = 'block';
document.getElementById('btn-ai-analyze').style.display = ''; document.getElementById('btn-ai-analyze').style.display = '';
refreshIcons();
try { try {
const result = await api.getAssessmentReport(currentAssessmentId); const result = await api.getAssessmentReport(currentAssessmentId);
@@ -637,29 +777,29 @@
let html = ''; let html = '';
if (analysis.summary) { if (analysis.summary) {
html += `<div style="margin-bottom:20px;"><h4 style="margin-bottom:8px;">Riepilogo</h4><p style="color:var(--gray-600); line-height:1.7;">${escapeHtml(analysis.summary)}</p></div>`; html += `<div class="ai-block" style="margin-bottom:20px;"><h4>Riepilogo</h4><p style="color:var(--muted); line-height:1.7;">${escapeHtml(analysis.summary)}</p></div>`;
} }
if (analysis.strengths && analysis.strengths.length > 0) { if (analysis.strengths && analysis.strengths.length > 0) {
html += `<div style="margin-bottom:20px;"><h4 style="margin-bottom:8px; color:var(--secondary);">Punti di Forza</h4><ul style="padding-left:20px; color:var(--gray-600);">`; html += `<div class="ai-block" style="margin-bottom:20px;"><h4 style="color:var(--c-green);">Punti di Forza</h4><ul>`;
analysis.strengths.forEach(s => { html += `<li style="margin-bottom:4px;">${escapeHtml(s)}</li>`; }); analysis.strengths.forEach(s => { html += `<li style="margin-bottom:4px;">${escapeHtml(s)}</li>`; });
html += '</ul></div>'; html += '</ul></div>';
} }
if (analysis.weaknesses && analysis.weaknesses.length > 0) { if (analysis.weaknesses && analysis.weaknesses.length > 0) {
html += `<div style="margin-bottom:20px;"><h4 style="margin-bottom:8px; color:var(--danger);">Aree di Miglioramento</h4><ul style="padding-left:20px; color:var(--gray-600);">`; html += `<div class="ai-block" style="margin-bottom:20px;"><h4 style="color:var(--c-red);">Aree di Miglioramento</h4><ul>`;
analysis.weaknesses.forEach(w => { html += `<li style="margin-bottom:4px;">${escapeHtml(w)}</li>`; }); analysis.weaknesses.forEach(w => { html += `<li style="margin-bottom:4px;">${escapeHtml(w)}</li>`; });
html += '</ul></div>'; html += '</ul></div>';
} }
if (analysis.recommendations && analysis.recommendations.length > 0) { if (analysis.recommendations && analysis.recommendations.length > 0) {
html += `<div><h4 style="margin-bottom:8px; color:var(--primary);">Raccomandazioni</h4><ol style="padding-left:20px; color:var(--gray-600);">`; html += `<div class="ai-block"><h4 style="color:var(--c-blue);">Raccomandazioni</h4><ol>`;
analysis.recommendations.forEach(r => { html += `<li style="margin-bottom:8px;">${escapeHtml(r)}</li>`; }); analysis.recommendations.forEach(r => { html += `<li style="margin-bottom:8px;">${escapeHtml(r)}</li>`; });
html += '</ol></div>'; html += '</ol></div>';
} }
if (!html) { if (!html) {
html = `<p style="color:var(--gray-600); line-height:1.7;">${escapeHtml(analysis.text || analysis.message || JSON.stringify(analysis))}</p>`; html = `<p style="color:var(--muted); line-height:1.7;">${escapeHtml(analysis.text || analysis.message || JSON.stringify(analysis))}</p>`;
} }
content.innerHTML = html; content.innerHTML = html;
@@ -681,7 +821,7 @@
const btn = document.getElementById('btn-generate-ncr'); const btn = document.getElementById('btn-generate-ncr');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0;"></div> Generazione in corso...'; btn.innerHTML = '<span class="spinner"></span> Generazione in corso...';
try { try {
const result = await api.createNCRsFromAssessment(currentAssessmentId); const result = await api.createNCRsFromAssessment(currentAssessmentId);
@@ -694,29 +834,31 @@
const majorCount = (result.data.created || []).filter(n => n.severity === 'major').length; const majorCount = (result.data.created || []).filter(n => n.severity === 'major').length;
const minorCount = total - majorCount; const minorCount = total - majorCount;
resultDiv.innerHTML = ` resultDiv.innerHTML = `
<div style="background:var(--secondary-bg); border:1px solid #bbf7d0; border-radius:var(--border-radius); padding:16px; text-align:left;"> <div style="background:#E6F4EC; border:1px solid #bbf7d0; border-radius:10px; padding:16px;">
<strong style="color:#15803d;">${total} non conformita' generate con successo</strong> <strong style="color:#15803d;">${total} non conformità generate con successo</strong>
<div style="margin-top:8px; font-size:0.8125rem; color:var(--gray-600);"> <div style="margin-top:8px; font-size:12.5px; color:var(--muted);">
${majorCount > 0 ? `<span style="color:var(--danger); font-weight:600;">${majorCount} major</span> ` : ''} ${majorCount > 0 ? `<span style="color:var(--c-red); font-weight:600;">${majorCount} major</span> ` : ''}
${minorCount > 0 ? `<span style="color:var(--warning); font-weight:600;">${minorCount} minor</span>` : ''} ${minorCount > 0 ? `<span style="color:var(--c-amber); font-weight:600;">${minorCount} minor</span>` : ''}
</div> </div>
</div>`; </div>`;
showNotification(`${total} non conformita' generate.`, 'success'); showNotification(`${total} non conformità generate.`, 'success');
} else { } else {
resultDiv.innerHTML = '<p style="color:var(--gray-500);">Nessun gap trovato — tutti i controlli sono implementati.</p>'; resultDiv.innerHTML = '<p class="text-muted">Nessun gap trovato — tutti i controlli sono implementati.</p>';
showNotification('Nessun gap trovato.', 'info'); showNotification('Nessun gap trovato.', 'info');
} }
btn.style.display = 'none'; btn.style.display = 'none';
} else { } else {
resultDiv.innerHTML = `<p style="color:var(--danger);">${escapeHtml(result.message || 'Errore nella generazione.')}</p>`; resultDiv.innerHTML = `<p style="color:var(--c-red);">${escapeHtml(result.message || 'Errore nella generazione.')}</p>`;
showNotification(result.message || 'Errore.', 'error'); showNotification(result.message || 'Errore.', 'error');
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/></svg> Genera Non Conformita\' dai Gap'; btn.innerHTML = '<i data-lucide="file-plus" class="ic"></i> Genera Non Conformità dai Gap';
refreshIcons();
} }
} catch (e) { } catch (e) {
showNotification('Errore di connessione.', 'error'); showNotification('Errore di connessione.', 'error');
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/></svg> Genera Non Conformita\' dai Gap'; btn.innerHTML = '<i data-lucide="file-plus" class="ic"></i> Genera Non Conformità dai Gap';
refreshIcons();
} }
} }
</script> </script>
+312 -55
View File
@@ -3,12 +3,12 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Inventario - NIS2 Agile</title> <title>Inventario · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css (self-host only, nessun CDN esterno). -->
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
via JS (tabelle/modali/form) -> parita' funzionale assoluta. BI veste la chrome. --> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <!-- style.css resta caricato DOPO v3.css: veste i widget renderizzati via JS
<link rel="stylesheet" href="/css/style.css?v=20260618"> (tabelle/modali/form/badge). È self-hostato (nessun CDN). v3.css governa chrome + shell. -->
<style> <style>
/* ── Stats Cards ────────────────────────────────────────── */ /* ── Stats Cards ────────────────────────────────────────── */
.asset-stats { .asset-stats {
@@ -105,6 +105,13 @@
.data-table tr:last-child td { border-bottom: none; } .data-table tr:last-child td { border-bottom: none; }
.data-table tbody tr { cursor: pointer; transition: background var(--transition-fast); } .data-table tbody tr { cursor: pointer; transition: background var(--transition-fast); }
.data-table tbody tr:hover td { background: var(--gray-50); } .data-table tbody tr:hover td { background: var(--gray-50); }
/* ── Intestazioni ordinabili (click per ordinare la lista) ── */
.data-table th.sortable-th { cursor: pointer; user-select: none; white-space: nowrap; transition: background var(--transition-fast), color var(--transition-fast); }
.data-table th.sortable-th:hover { background: var(--gray-100); color: var(--gray-700); }
.data-table th.sortable-th:focus-visible { outline: 2px solid var(--primary, #0066CC); outline-offset: -2px; }
.data-table th.sortable-th.sorted { color: var(--gray-700); }
.data-table th.sortable-th .sort-ind { font-size: 0.7rem; opacity: 0.35; margin-left: 3px; }
.data-table th.sortable-th .sort-ind.active { opacity: 1; color: var(--primary, #0066CC); }
/* ── Criticality Badges ─────────────────────────────────── */ /* ── Criticality Badges ─────────────────────────────────── */
.criticality-badge { .criticality-badge {
@@ -273,18 +280,26 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content"> <main class="wrap">
<header class="content-header"> <nav class="crumb" aria-label="breadcrumb">
<h1 class="h2" data-i18n="assets.title">Inventario</h1> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<div class="content-header-actions"> <i data-lucide="chevron-right" class="ic"></i><span data-i18n="assets.title">Inventario</span>
<span class="text-muted" style="font-size:0.8rem; margin-right:8px;">Art. 21.2.i NIS2</span> </nav>
<button class="btn btn-secondary" onclick="openImportModal()" title="Importa beni da CSV/CMDB">Importa</button> <div class="page-head">
<button class="btn btn-primary" onclick="showCreateAssetModal()">+ Nuovo bene</button> <div>
</div> <h1 data-i18n="assets.title">Inventario</h1>
</header> <p class="sub">Beni, perimetro tecnico e rilevanza NIS2 · Art. 21.2.i NIS2</p>
</div>
<div class="content-header-actions" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<button class="btn-ghost" onclick="showDependencyMap()" title="Mappa delle dipendenze tra i beni"><i data-lucide="git-fork" class="ic"></i>Mappa Dipendenze</button>
<button class="btn-ghost" onclick="openImportModal()" title="Importa beni da CSV/CMDB"><i data-lucide="upload" class="ic"></i>Importa</button>
<button class="btn-ghost" onclick="exportAssets()" title="Esporta l'inventario in CSV (stesso formato dell'importazione)"><i data-lucide="download" class="ic"></i>Esporta</button>
<button class="btn-primary" onclick="showCreateAssetModal()"><i data-lucide="plus" class="ic"></i>Nuovo bene</button>
</div>
</div>
<div class="content-body"> <div class="content-body">
<!-- Stats Cards --> <!-- Stats Cards -->
@@ -358,29 +373,23 @@
</div> </div>
</div> </div>
</div> </div>
</main> </main>
</div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="/js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → pagina -->
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <script src="/js/api.js?v=20260730a"></script>
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, <script src="/js/common.js?v=20260729b"></script>
riusando tutti gli helper di common.js (zero backend). --> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script> <script src="/js/i18n.js?v=20260731"></script>
if (window.bootstrap && bootstrap.loadFonts) { <script src="/js/help.js?v=20260806b"></script>
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</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> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
I18n.init(); I18n.init();
HelpSystem.init(); HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
loadSubclasses(); // C4 — voci+sottoclassi per i selettori (fire-and-forget, pronto al primo "Nuovo") loadSubclasses(); // C4 — voci+sottoclassi per i selettori (fire-and-forget, pronto al primo "Nuovo")
// ── Labels ────────────────────────────────────────────── // ── Labels ──────────────────────────────────────────────
@@ -442,6 +451,33 @@
else showNotification((r && r.message) || 'Errore.', 'warning'); else showNotification((r && r.message) || 'Errore.', 'warning');
} catch (e) { showNotification('Errore aggiunta sottoclasse.', 'warning'); } } catch (e) { showNotification('Errore aggiunta sottoclasse.', 'warning'); }
} }
async function renameSubclassPrompt() {
const sel = document.getElementById('asset-subclass');
const id = sel.value;
if (!id) { showNotification('Seleziona prima una sottoclasse da rinominare.', 'info'); return; }
const cur = sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : '';
const label = (prompt('Nuovo nome per la sottoclasse:', cur) || '').trim();
if (!label || label === cur) return;
const voce = document.getElementById('asset-voce').value;
try {
const r = await api.updateAssetSubclass(id, { label: label });
if (r && r.success) { await loadSubclasses(); document.getElementById('asset-subclass').innerHTML = subclassOptionsHtml(voce, id); showNotification('Sottoclasse rinominata.', 'success'); }
else showNotification((r && r.message) || 'Operazione non consentita (solo sottoclassi della tua organizzazione).', 'warning');
} catch (e) { showNotification('Errore rinomina sottoclasse.', 'warning'); }
}
async function removeSubclassPrompt() {
const sel = document.getElementById('asset-subclass');
const id = sel.value;
if (!id) { showNotification('Seleziona prima una sottoclasse da rimuovere.', 'info'); return; }
const cur = sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : '';
if (!confirm('Rimuovere la sottoclasse «' + cur + '»? Gli asset che la usano resteranno senza sottoclasse.')) return;
const voce = document.getElementById('asset-voce').value;
try {
const r = await api.deleteAssetSubclass(id);
if (r && r.success) { await loadSubclasses(); document.getElementById('asset-subclass').innerHTML = subclassOptionsHtml(voce, ''); showNotification('Sottoclasse rimossa.', 'success'); }
else showNotification((r && r.message) || 'Operazione non consentita (solo sottoclassi della tua organizzazione).', 'warning');
} catch (e) { showNotification('Errore rimozione sottoclasse.', 'warning'); }
}
// ── State ─────────────────────────────────────────────── // ── State ───────────────────────────────────────────────
let allAssets = []; let allAssets = [];
@@ -506,8 +542,57 @@
).join(''); ).join('');
} }
// ── Ordinamento colonne (client-side, sull'inventario gia' caricato) ──
// L'inventario e' caricato per intero lato client (nessuna paginazione),
// quindi l'ordinamento sull'array e' completo e non parziale.
let lastView = [];
let sortState = { key: null, dir: 1 }; // dir: 1 = crescente, -1 = decrescente
const ASSET_COLUMNS = [
{ key: 'name', label: 'Nome', type: 'text', val: a => (a.name || '') },
{ key: 'type', label: 'Tipo', type: 'text', val: a => assetTipo(a) },
{ key: 'category', label: 'Categoria', type: 'text', val: a => (a.category || '') },
{ key: 'criticality', label: "Criticita'", type: 'num', val: a => ({ low: 1, medium: 2, high: 3, critical: 4 }[a.criticality || 'medium'] || 0) },
{ key: 'relevance', label: 'Rilevanza NIS2', type: 'num', val: a => (a.relevance_score == null ? -1 : Number(a.relevance_score)) },
{ key: 'owner', label: 'Owner', type: 'text', val: a => (a.owner_name || '') },
{ key: 'status', label: 'Stato', type: 'text', val: a => (a.status || '') }
];
function sortableTh(c) {
const active = sortState.key === c.key;
const ariaSort = active ? (sortState.dir === 1 ? 'ascending' : 'descending') : 'none';
const ind = active ? (sortState.dir === 1 ? '▲' : '▼') : '⇅';
const indClass = active ? 'sort-ind active' : 'sort-ind';
return `<th class="sortable-th${active ? ' sorted' : ''}" role="button" tabindex="0" aria-sort="${ariaSort}" `
+ `title="Ordina per ${escapeHtml(c.label)}" onclick="sortBy('${c.key}')" `
+ `onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();sortBy('${c.key}');}">`
+ `${escapeHtml(c.label)} <span class="${indClass}" aria-hidden="true">${ind}</span></th>`;
}
function sortAssetsView(assets) {
if (!sortState.key) return assets;
const col = ASSET_COLUMNS.find(c => c.key === sortState.key);
if (!col) return assets;
const copy = assets.slice();
copy.sort((a, b) => {
const va = col.val(a), vb = col.val(b);
let cmp;
if (col.type === 'num') cmp = (Number(va) - Number(vb));
else cmp = String(va).localeCompare(String(vb), 'it', { numeric: true, sensitivity: 'base' });
if (cmp === 0) return 0;
return cmp * sortState.dir;
});
return copy;
}
function sortBy(key) {
if (sortState.key === key) sortState.dir *= -1;
else { sortState.key = key; sortState.dir = 1; }
renderAssets(lastView);
}
function renderAssets(assets) { function renderAssets(assets) {
const container = document.getElementById('assets-container'); const container = document.getElementById('assets-container');
lastView = assets || [];
if (!assets || assets.length === 0) { if (!assets || assets.length === 0) {
container.innerHTML = ` container.innerHTML = `
@@ -519,23 +604,16 @@
return; return;
} }
const sortedAssets = sortAssetsView(assets);
let html = ` let html = `
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>${ASSET_COLUMNS.map(sortableTh).join('')}<th>Azioni</th></tr>
<th>Nome</th>
<th>Tipo</th>
<th>Categoria</th>
<th>Criticita'</th>
<th>Rilevanza NIS2</th>
<th>Owner</th>
<th>Stato</th>
<th>Azioni</th>
</tr>
</thead> </thead>
<tbody>`; <tbody>`;
assets.forEach(asset => { sortedAssets.forEach(asset => {
const crit = asset.criticality || 'medium'; const crit = asset.criticality || 'medium';
const st = asset.status || 'active'; const st = asset.status || 'active';
html += ` html += `
@@ -554,6 +632,9 @@
<button class="btn-icon" onclick="event.stopPropagation(); showEditAssetModal(${asset.id})" title="Modifica"> <button class="btn-icon" onclick="event.stopPropagation(); showEditAssetModal(${asset.id})" title="Modifica">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg> <svg viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
</button> </button>
<button class="btn-icon" onclick="event.stopPropagation(); confirmDeleteAsset(${asset.id})" title="Elimina bene">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
</button>
</td> </td>
</tr>`; </tr>`;
}); });
@@ -601,19 +682,24 @@
Il punteggio aggiorna automaticamente anche la criticita del bene.</p>`; Il punteggio aggiorna automaticamente anche la criticita del bene.</p>`;
for (const [key, def] of Object.entries(grid.grid)) { for (const [key, def] of Object.entries(grid.grid)) {
const sel = prev && prev[key] ? prev[key].value : ''; // relevance_criteria e' salvato come mappa piatta {key: "optionValue"} (vedi AssetController::score).
// Gestiamo anche la forma legacy a oggetto {value:...} per retro-compatibilita'.
const pv = prev ? prev[key] : null;
const sel = (pv != null) ? (typeof pv === 'object' ? (pv.value || '') : pv) : '';
let opts = `<option value="">— seleziona —</option>`; let opts = `<option value="">— seleziona —</option>`;
for (const [ov, od] of Object.entries(def.options)) { for (const [ov, od] of Object.entries(def.options)) {
opts += `<option value="${ov}" data-pts="${od.points}" ${ov === sel ? 'selected' : ''}>${od.label} (${od.points})</option>`; opts += `<option value="${ov}" data-pts="${od.points}" data-lbl="${escapeHtml(od.label)}" data-desc="${escapeHtml(od.desc || '')}" ${ov === sel ? 'selected' : ''}>${od.label} (${od.points})</option>`;
} }
body += `<div class="form-group" style="margin-bottom:0.75rem;"> body += `<div class="form-group" style="margin-bottom:0.75rem;">
<label class="form-label" style="font-weight:600;">${def.label} <span style="color:var(--gray-400);font-weight:400;">(max ${def.max})</span></label> <label class="form-label" style="font-weight:600;">${def.label} <span style="color:var(--gray-400);font-weight:400;">(max ${def.max})</span></label>
<div style="font-size:0.78rem;color:var(--gray-500);margin-bottom:4px;">${def.help}</div> <div style="font-size:0.78rem;color:var(--gray-500);margin-bottom:4px;">${def.help}</div>
<select class="form-select score-criterion" data-key="${key}" onchange="updateScorePreview()" aria-label="${def.label}">${opts}</select> <select class="form-select score-criterion" data-key="${key}" data-crit-label="${escapeHtml(def.label)}" data-max="${def.max}" onchange="updateScorePreview()" aria-label="${def.label}">${opts}</select>
<div class="score-meaning" id="meaning-${key}" style="font-size:0.78rem;color:var(--gray-600);margin-top:4px;min-height:1.1em;font-style:italic;"></div>
</div>`; </div>`;
} }
body += `<div id="score-preview" style="margin-top:1rem;padding:0.9rem;border-radius:10px;background:var(--gray-50);font-weight:600;text-align:center;"> body += `<div id="score-preview" style="margin-top:1rem;padding:0.9rem;border-radius:10px;background:var(--gray-50);font-weight:600;text-align:center;">
Totale: <span id="score-total">0</span>/100 — <span id="score-class">—</span></div>`; Totale: <span id="score-total">0</span>/100 — <span id="score-class">—</span></div>
<div id="score-breakdown" style="margin-top:0.6rem;font-size:0.8rem;color:var(--gray-700);"></div>`;
showModal(`Valuta Rilevanza NIS2 — ${escapeHtml(a.name)}`, body, { showModal(`Valuta Rilevanza NIS2 — ${escapeHtml(a.name)}`, body, {
size: 'lg', size: 'lg',
@@ -628,9 +714,23 @@
function updateScorePreview() { function updateScorePreview() {
let total = 0, complete = true; let total = 0, complete = true;
let rows = '';
document.querySelectorAll('.score-criterion').forEach(s => { document.querySelectorAll('.score-criterion').forEach(s => {
if (!s.value) { complete = false; return; } const meaningEl = document.getElementById('meaning-' + s.dataset.key);
total += parseInt(s.selectedOptions[0].dataset.pts || '0', 10); if (!s.value) {
complete = false;
if (meaningEl) meaningEl.textContent = '';
return;
}
const opt = s.selectedOptions[0];
const pts = parseInt(opt.dataset.pts || '0', 10);
total += pts;
// #432: mostra il significato dell'opzione selezionata sotto la combo
if (meaningEl) meaningEl.textContent = opt.dataset.desc || '';
// #431: dettaglio per criterio del punteggio raggiunto
rows += `<div style="display:flex;justify-content:space-between;gap:0.5rem;padding:3px 0;border-bottom:1px solid var(--gray-100);">
<span>${escapeHtml(s.dataset.critLabel)}: ${escapeHtml(opt.dataset.lbl || '')}</span>
<span style="font-weight:600;white-space:nowrap;">${pts}/${s.dataset.max}</span></div>`;
}); });
let cls = total >= 80 ? 'critico' : total >= 60 ? 'alto' : total >= 40 ? 'medio' : total >= 20 ? 'basso' : 'trascurabile'; let cls = total >= 80 ? 'critico' : total >= 60 ? 'alto' : total >= 40 ? 'medio' : total >= 20 ? 'basso' : 'trascurabile';
const color = relevanceClassColors[cls]; const color = relevanceClassColors[cls];
@@ -638,6 +738,10 @@
const clsEl = document.getElementById('score-class'); const clsEl = document.getElementById('score-class');
clsEl.textContent = complete ? `${cls.charAt(0).toUpperCase() + cls.slice(1)}${total >= 40 ? ' — Rilevante NIS2 ✓' : ''}` : '(completa tutti i criteri)'; clsEl.textContent = complete ? `${cls.charAt(0).toUpperCase() + cls.slice(1)}${total >= 40 ? ' — Rilevante NIS2 ✓' : ''}` : '(completa tutti i criteri)';
clsEl.style.color = complete ? color : 'var(--gray-400)'; clsEl.style.color = complete ? color : 'var(--gray-400)';
const bdEl = document.getElementById('score-breakdown');
if (bdEl) bdEl.innerHTML = rows
? `<div style="font-weight:600;margin-bottom:4px;">Dettaglio punteggio per criterio</div>${rows}`
: '';
} }
async function submitScoring(id) { async function submitScoring(id) {
@@ -682,7 +786,10 @@
try { deps = JSON.parse(deps); } catch(e) { deps = []; } try { deps = JSON.parse(deps); } catch(e) { deps = []; }
} }
if (deps && deps.length > 0) { if (deps && deps.length > 0) {
depsHtml = deps.map(d => `<span class="dep-tag">ID: ${escapeHtml(String(d))}</span>`).join(''); depsHtml = deps.map(d => {
const dep = (allAssets || []).find(x => x.id === parseInt(d));
return `<span class="dep-tag">${dep ? escapeHtml(dep.name) : ('ID: ' + escapeHtml(String(d)))}</span>`;
}).join('');
} }
} }
@@ -747,6 +854,7 @@
size: 'lg', size: 'lg',
footer: ` footer: `
<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button> <button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>
<button class="btn btn-danger" onclick="confirmDeleteAsset(${a.id})">Elimina</button>
<button class="btn btn-secondary" onclick="closeModal(); showScoringModal(${a.id})">Valuta Rilevanza NIS2</button> <button class="btn btn-secondary" onclick="closeModal(); showScoringModal(${a.id})">Valuta Rilevanza NIS2</button>
<button class="btn btn-primary" onclick="closeModal(); showEditAssetModal(${a.id})">Modifica</button> <button class="btn btn-primary" onclick="closeModal(); showEditAssetModal(${a.id})">Modifica</button>
` `
@@ -818,6 +926,15 @@
`<option value="${val}" ${a.status === val ? 'selected' : ''}>${label}</option>` `<option value="${val}" ${a.status === val ? 'selected' : ''}>${label}</option>`
).join(''); ).join('');
// Dipendenze (mappa delle dipendenze): opzioni = tutti gli altri beni
let curDeps = a.dependencies;
if (typeof curDeps === 'string') { try { curDeps = JSON.parse(curDeps); } catch (e) { curDeps = []; } }
curDeps = (curDeps || []).map(x => parseInt(x));
const depOptions = (allAssets || [])
.filter(x => x.id !== a.id)
.map(x => `<option value="${x.id}" ${curDeps.includes(x.id) ? 'selected' : ''}>${escapeHtml(x.name)}</option>`)
.join('') || '<option value="" disabled>Nessun altro bene disponibile in inventario</option>';
// Store the callback // Store the callback
window._assetSaveCallback = onSave; window._assetSaveCallback = onSave;
@@ -832,7 +949,11 @@
<select class="form-select" id="asset-voce" onchange="onVoceChange()">${voceOpts}</select> <select class="form-select" id="asset-voce" onchange="onVoceChange()">${voceOpts}</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="asset-subclass" class="form-label">Sottoclasse <a href="#" onclick="addSubclassPrompt();return false;" style="font-weight:400;font-size:.78rem;margin-left:6px;">+ aggiungi</a></label> <label for="asset-subclass" class="form-label">Sottoclasse
<a href="#" onclick="addSubclassPrompt();return false;" style="font-weight:400;font-size:.78rem;margin-left:6px;">+ aggiungi</a>
<a href="#" onclick="renameSubclassPrompt();return false;" style="font-weight:400;font-size:.78rem;margin-left:8px;">✎ rinomina</a>
<a href="#" onclick="removeSubclassPrompt();return false;" style="font-weight:400;font-size:.78rem;margin-left:8px;">🗑 rimuovi</a>
</label>
<select class="form-select" id="asset-subclass">${subOpts}</select> <select class="form-select" id="asset-subclass">${subOpts}</select>
</div> </div>
</div> </div>
@@ -894,6 +1015,11 @@
<input type="date" class="form-input" id="asset-warranty" value="${a.warranty_expiry || ''}"> <input type="date" class="form-input" id="asset-warranty" value="${a.warranty_expiry || ''}">
</div> </div>
</div> </div>
<div class="form-group">
<label for="asset-dependencies" class="form-label">Dipendenze (mappa delle dipendenze)</label>
<select class="form-select" id="asset-dependencies" multiple size="5" aria-describedby="dep-help">${depOptions}</select>
<small id="dep-help" class="form-text text-muted" style="display:block;margin-top:4px;">Seleziona i beni da cui questo bene dipende (tieni premuto Ctrl/Cmd per selezionarne piu' di uno). Alimenta la Mappa delle Dipendenze e supporta l'analisi del rischio (art. 24 D.Lgs. 138/2024).</small>
</div>
`, { `, {
size: 'lg', size: 'lg',
footer: ` footer: `
@@ -928,11 +1054,66 @@
status: document.getElementById('asset-status').value, status: document.getElementById('asset-status').value,
}; };
const depSel = document.getElementById('asset-dependencies');
data.dependencies = depSel
? Array.from(depSel.selectedOptions).map(o => parseInt(o.value)).filter(n => !isNaN(n))
: [];
if (window._assetSaveCallback) { if (window._assetSaveCallback) {
window._assetSaveCallback(data); window._assetSaveCallback(data);
} }
} }
// ── Elimina bene ────────────────────────────────────────
async function confirmDeleteAsset(id) {
const a = (allAssets || []).find(x => x.id === parseInt(id));
const nome = a ? a.name : ('#' + id);
if (!confirm(`Eliminare definitivamente il bene "${nome}"?\nL'operazione non e' reversibile.`)) return;
try {
const r = await api.deleteAsset(id);
if (r.success) {
showNotification('Bene eliminato dall\'inventario.', 'success');
closeModal();
loadAssets();
} else {
showNotification(r.message || 'Eliminazione non riuscita (serve il ruolo amministratore).', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
// ── Mappa delle Dipendenze ──────────────────────────────
function parseAssetDeps(a) {
let d = a.dependencies;
if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { d = []; } }
return (d || []).map(x => parseInt(x)).filter(n => !isNaN(n));
}
function showDependencyMap() {
const assets = allAssets || [];
const byId = {};
assets.forEach(a => { byId[a.id] = a; });
const total = assets.reduce((s, a) => s + parseAssetDeps(a).length, 0);
let body;
if (!assets.length) {
body = `<p style="font-size:.9rem;color:var(--gray-600);">Nessun bene in inventario. Aggiungi prima i beni, poi apri <strong>Modifica</strong> su ciascuno per impostare le dipendenze.</p>`;
} else {
const rows = assets.map(a => {
const deps = parseAssetDeps(a);
const depNames = deps.length
? deps.map(id => `<span class="dep-tag">${byId[id] ? escapeHtml(byId[id].name) : ('#' + id)}</span>`).join(' ')
: '<span style="color:var(--gray-400);">— nessuna dipendenza</span>';
return `<tr><td style="font-weight:500;">${escapeHtml(a.name)}</td><td>${depNames}</td></tr>`;
}).join('');
body = `<p style="font-size:.85rem;color:var(--gray-600);margin-bottom:.8rem;">Relazioni di dipendenza tra i beni dell'inventario (${total} collegamenti totali). Per impostare o modificare le dipendenze di un bene, aprilo con <strong>Modifica</strong> e usa il campo <em>Dipendenze</em>. La mappa supporta l'analisi del rischio e la continuita' operativa (art. 24 D.Lgs. 138/2024).</p>
<table class="data-table"><thead><tr><th>Bene</th><th>Dipende da</th></tr></thead><tbody>${rows}</tbody></table>`;
}
showModal('Mappa delle Dipendenze', body, {
size: 'lg',
footer: `<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>`
});
}
// ── Initial Load ──────────────────────────────────────── // ── Initial Load ────────────────────────────────────────
loadAssets(); loadAssets();
@@ -958,13 +1139,32 @@ function impFile(e){
const f=e.target.files[0]; if(!f)return; const f=e.target.files[0]; if(!f)return;
const r=new FileReader(); r.onload=()=>{document.getElementById('imp-csv').value=r.result;}; r.readAsText(f); const r=new FileReader(); r.onload=()=>{document.getElementById('imp-csv').value=r.result;}; r.readAsText(f);
} }
// Split di una riga CSV gestendo virgolette ed escape (""), così nomi con
// virgola/virgolette importano correttamente (round-trip con l'export). Le righe
// non quotate si comportano come prima (retro-compatibile).
function splitCsvLine(line){
const out=[]; let cur=''; let inQ=false;
for(let i=0;i<line.length;i++){
const ch=line[i];
if(inQ){
if(ch==='"'){ if(line[i+1]==='"'){ cur+='"'; i++; } else inQ=false; }
else cur+=ch;
} else {
if(ch==='"') inQ=true;
else if(ch===',') { out.push(cur); cur=''; }
else cur+=ch;
}
}
out.push(cur);
return out;
}
function parseCsv(text){ function parseCsv(text){
const lines=text.trim().split(/\r?\n/).filter(l=>l.trim()); const lines=text.trim().split(/\r?\n/).filter(l=>l.trim());
if(lines.length<2)return []; if(lines.length<2)return [];
const headers=lines[0].split(',').map(h=>h.trim().toLowerCase()); const headers=splitCsvLine(lines[0]).map(h=>h.trim().toLowerCase());
const truthy=v=>/^(1|true|si|sì|yes|y)$/i.test((v||'').trim()); const truthy=v=>/^(1|true|si|sì|yes|y)$/i.test((v||'').trim());
return lines.slice(1).map(line=>{ return lines.slice(1).map(line=>{
const cells=line.split(','); const o={}; const cells=splitCsvLine(line); const o={};
headers.forEach((h,i)=>{ o[h]=(cells[i]||'').trim(); }); headers.forEach((h,i)=>{ o[h]=(cells[i]||'').trim(); });
const a={name:o.name, asset_type:o.asset_type, criticality:o.criticality, const a={name:o.name, asset_type:o.asset_type, criticality:o.criticality,
data_classification:o.data_classification, vendor:o.vendor, location:o.location, data_classification:o.data_classification, vendor:o.vendor, location:o.location,
@@ -989,6 +1189,63 @@ async function doImport(){
}catch(e){showNotification('Errore di connessione','error');} }catch(e){showNotification('Errore di connessione','error');}
} }
/* ── Export inventario (stesso formato dell'import) #441 ──── */
// Stesse colonne riconosciute dall'import: re-importando il file si ricostruisce
// l'inventario (lo scoring di rilevanza NIS2 viene ricalcolato in import).
const EXPORT_COLS = ['name','asset_type','criticality','data_classification','internet_facing','dependencies_count','regulated','external_ref','vendor','location'];
function csvCell(v){
const s = (v===null||v===undefined) ? '' : String(v).replace(/[\r\n]+/g,' ');
return /[",]/.test(s) ? '"'+s.replace(/"/g,'""')+'"' : s;
}
// Ricostruisce le colonne di import dai criteri di scoring salvati (relevance_criteria).
// Le coppie sono reversibili: re-importando si riottengono gli stessi criteri.
function assetToExportRow(a){
let crit = {};
try { crit = (typeof a.relevance_criteria === 'string') ? JSON.parse(a.relevance_criteria||'{}') : (a.relevance_criteria||{}); } catch(e){ crit = {}; }
const dcMap = { gdpr_art9:'special', personal_large:'personal', personal_fin:'financial', confidential:'confidential', public:'public' };
const depMap = { ge5_critical:5, n3_4_critical:3, n2_critical:2, n1_critical:1, none:0 };
const c4 = crit.c4_dependencies;
return {
name: a.name || '',
asset_type: a.asset_type || '',
criticality: a.criticality || '',
data_classification: dcMap[crit.c3_data_processed] || '',
internet_facing: /^internet/.test(crit.c5_exposure || '') ? 'true' : 'false',
dependencies_count: (c4 in depMap) ? depMap[c4] : 0,
regulated: (crit.c6_regulatory && crit.c6_regulatory !== 'none') ? 'true' : 'false',
external_ref: a.external_ref || '',
vendor: a.vendor || '',
location: a.location || ''
};
}
async function exportAssets(){
showNotification('Preparazione esportazione…','info');
let items=[], page=1, total=Infinity;
try{
while(items.length < total){
const res = await api.listAssets({ per_page:200, page });
if(!res || !res.success){ showNotification((res&&res.message)||'Errore durante l\'esportazione','error'); return; }
const d = res.data || {};
const batch = d.items || (Array.isArray(d) ? d : []);
total = (typeof d.total === 'number') ? d.total : batch.length;
items = items.concat(batch);
if(!batch.length || page>50) break;
page++;
}
}catch(e){ showNotification('Errore di connessione durante l\'esportazione','error'); return; }
if(!items.length){ showNotification('Inventario vuoto: niente da esportare','warning'); return; }
const lines = [ EXPORT_COLS.join(',') ];
items.map(assetToExportRow).forEach(r => lines.push(EXPORT_COLS.map(c => csvCell(r[c])).join(',')));
const csv = '' + lines.join('\r\n'); // BOM per Excel
const stamp = new Date().toISOString().slice(0,10);
const blob = new Blob([csv], { type:'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const el = document.createElement('a');
el.href = url; el.download = `inventario-nis2-${stamp}.csv`;
document.body.appendChild(el); el.click(); el.remove(); URL.revokeObjectURL(url);
showNotification(`Esportati ${items.length} beni nel formato di importazione`,'success');
}
</script> </script>
</body> </body>
</html> </html>
+205 -166
View File
@@ -3,65 +3,10 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Calendario scadenze - NIS2 Agile</title> <title>Calendario · NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <!-- UI V3: Inter self-host + design system v3.css. Self-host only: no external font/script hosts. -->
<link rel="stylesheet" href="/css/style.css?v=20260618"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<style> <link rel="stylesheet" href="/css/v3.css?v=20260627">
.cal-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
.cal-note { font-size:.82rem; color:#3730a3; background:#eef2ff; border:1px solid #c7d2fe; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
.cal-bar { display:flex; flex-wrap:wrap; gap:12px; align-items:center; justify-content:space-between; margin-bottom:14px; }
.cal-nav { display:flex; align-items:center; gap:8px; }
.cal-nav h3 { margin:0; font-size:1.05rem; min-width:170px; text-align:center; text-transform:capitalize; }
.cal-views { display:flex; gap:6px; }
.cal-view-btn { padding:7px 14px; font-weight:600; font-size:.85rem; cursor:pointer; border:1px solid var(--gray-200,#e5e7eb); background:#fff; color:var(--gray-600,#4b5563); border-radius:8px; }
.cal-view-btn.active { background:var(--primary,#2563eb); color:#fff; border-color:var(--primary,#2563eb); }
.cal-summary { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:14px; }
.cal-kpi { flex:1 1 0; min-width:120px; background:#fff; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; padding:10px 14px; }
.cal-kpi .n { font-size:1.5rem; font-weight:800; line-height:1; }
.cal-kpi .l { font-size:.74rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); margin-top:4px; }
.cal-kpi.k-overdue .n { color:#b91c1c; } .cal-kpi.k-due_soon .n { color:#b45309; }
.cal-kpi.k-upcoming .n { color:#1d4ed8; } .cal-kpi.k-done .n { color:#15803d; }
.cal-filters { background:#f8fafc; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; padding:12px 14px; margin-bottom:14px; }
.cal-filters h4 { margin:0 0 8px; font-size:.78rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
.cal-chips { display:flex; flex-wrap:wrap; gap:8px; }
.cal-chip { display:inline-flex; align-items:center; gap:6px; font-size:.82rem; padding:5px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:20px; background:#fff; cursor:pointer; user-select:none; }
.cal-chip input { margin:0; cursor:pointer; }
.cal-chip .dot { width:10px; height:10px; border-radius:50%; display:inline-block; }
.cal-filter-row { margin-top:10px; }
table.cal-grid { width:100%; border-collapse:collapse; table-layout:fixed; }
table.cal-grid th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); padding:6px 4px; text-align:center; }
table.cal-grid td { border:1px solid var(--gray-100,#f3f4f6); vertical-align:top; height:96px; padding:4px; background:#fff; }
table.cal-grid td.cal-out { background:#fafafa; }
table.cal-grid td.cal-today { background:#fffbeb; outline:2px solid #fbbf24; outline-offset:-2px; }
.cal-daynum { font-size:.78rem; font-weight:600; color:var(--gray-600,#4b5563); }
.cal-day-dots { display:flex; flex-wrap:wrap; gap:3px; margin-top:4px; }
.cal-dot { width:9px; height:9px; border-radius:50%; cursor:pointer; }
.cal-more { font-size:.7rem; color:var(--gray-500,#6b7280); cursor:pointer; margin-top:3px; }
.st-overdue { background:#dc2626; } .st-due_soon { background:#d97706; }
.st-upcoming { background:#2563eb; } .st-done { background:#16a34a; }
.cal-list { width:100%; border-collapse:collapse; font-size:.88rem; }
.cal-list th, .cal-list td { text-align:left; padding:9px 12px; border-top:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
.cal-list th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
.cal-list tr.row-overdue td { background:#fef2f2; }
.cal-list a.cal-link { color:var(--primary,#2563eb); text-decoration:none; font-weight:600; }
.cal-list a.cal-link:hover { text-decoration:underline; }
.st-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; white-space:nowrap; color:#fff; }
.st-badge.st-overdue { background:#dc2626; } .st-badge.st-due_soon { background:#d97706; }
.st-badge.st-upcoming { background:#2563eb; } .st-badge.st-done { background:#16a34a; }
.cal-empty { text-align:center; padding:22px 16px; color:var(--gray-500,#6b7280); font-size:.86rem; }
.hidden { display:none !important; }
.type-tag { display:inline-block; font-size:.7rem; padding:2px 7px; border-radius:6px; background:#eef2ff; color:#3730a3; }
/* popover giorno */
.cal-pop { position:fixed; inset:0; background:rgba(15,23,42,.4); z-index:1050; display:none; align-items:flex-start; justify-content:center; padding:40px 16px; overflow:auto; }
.cal-pop.open { display:flex; }
.cal-pop-card { background:#fff; border-radius:12px; width:100%; max-width:520px; box-shadow:0 20px 50px rgba(0,0,0,.25); }
.cal-pop-head { display:flex; justify-content:space-between; align-items:center; padding:14px 18px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
.cal-pop-head h3 { margin:0; font-size:1rem; }
.cal-pop-body { padding:14px 18px; }
.cal-pop-item { display:flex; gap:8px; align-items:flex-start; padding:8px 0; border-top:1px solid var(--gray-100,#f3f4f6); }
.cal-pop-item:first-child { border-top:none; }
.cal-pop-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); }
</style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC"> <meta name="theme-color" content="#0066CC">
@@ -75,94 +20,189 @@
<meta name="application-name" content="NIS2 Agile"> <meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script> <script src="/js/pwa.js?v=20260614" defer></script>
<!-- PWA:end --> <!-- PWA:end -->
<style>
/* ── Calendario V3 — shell app full-page (fonte: docs/mockup Ui/calendario.html).
Solo classi NON coperte da v3.css; token condivisi via v3.css. Il calendario reale
è un AGGREGATORE READ-ONLY (api.calendarEvents/calendarSummary): niente create/edit
eventi → dal mockup riusiamo .cal-app/.cal-side/.cal-main/.mv-grid/.view-switch/.legend,
wiring sui dati reali. Le due viste reali sono Griglia (mese) e Lista. */
.cal-app{display:grid;grid-template-columns:250px 1fr;gap:18px;align-items:start}
.cal-side{display:flex;flex-direction:column;gap:16px}
/* KPI sintetici (by_status) */
.cal-kpis{display:grid;gap:10px}
.cal-kpi{background:#fff;border:1px solid var(--line);border-radius:12px;padding:12px 14px;box-shadow:var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:10px}
.cal-kpi .n{font-size:1.5rem;font-weight:800;line-height:1}
.cal-kpi .l{font-size:.74rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
.cal-kpi.k-overdue .n{color:var(--c-red)} .cal-kpi.k-due_soon .n{color:#b45309}
.cal-kpi.k-upcoming .n{color:var(--c-blue)} .cal-kpi.k-done .n{color:#15803d}
/* legenda categorie + filtri */
.legend{background:#fff;border:1px solid var(--line);border-radius:12px;padding:14px;box-shadow:var(--shadow)}
.legend p{margin:0 0 9px;font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#8093A8}
.cal-chips{display:flex;flex-direction:column;gap:4px}
.cal-chip{display:inline-flex;align-items:center;gap:9px;font-size:12.5px;padding:5px 4px;border-radius:7px;cursor:pointer;user-select:none}
.cal-chip:hover{background:#F3F7FC}
.cal-chip input{margin:0;cursor:pointer;accent-color:var(--c-blue);flex:0 0 auto}
.cal-chip .dot{width:11px;height:11px;border-radius:3px;display:inline-block;flex:0 0 auto}
.cal-chip .lbl{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* main */
.cal-main{min-width:0}
.cal-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}
.tb-left{display:flex;align-items:center;gap:10px}
.tb-nav{display:flex;gap:2px}
.tb-title{font-size:19px;font-weight:800;text-transform:capitalize;letter-spacing:-.3px;margin-left:4px}
.view-switch{display:inline-flex;background:#E7EDF4;border-radius:9px;padding:3px}
.view-switch button{border:0;background:transparent;font:inherit;font-weight:600;font-size:12.5px;color:var(--muted);padding:6px 14px;border-radius:7px;cursor:pointer}
.view-switch button.on{background:#fff;color:var(--ink);box-shadow:0 1px 2px rgba(16,40,80,.12)}
/* vista mese (griglia) */
.mv{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;box-shadow:var(--shadow)}
.mv-dow{display:grid;grid-template-columns:repeat(7,1fr);background:#F6F9FC;border-bottom:1px solid var(--line)}
.mv-dow div{padding:9px 8px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#7a8aa0}
.mv-grid{display:grid;grid-template-columns:repeat(7,1fr)}
.mc{min-height:108px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);padding:6px 7px;position:relative;overflow:hidden}
.mc:nth-child(7n){border-right:0}
.mc.has{cursor:pointer}
.mc.has:hover{background:#FAFCFE}
.mc .dnum{font-size:12.5px;font-weight:600;width:25px;height:25px;display:flex;align-items:center;justify-content:center;border-radius:50%;color:var(--ink)}
.mc.other{background:#FAFBFD}.mc.other .dnum{color:#AEBAC8}
.mc.today .dnum{background:var(--c-blue);color:#fff}
.mc .chip{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:600;color:var(--ink);border-radius:5px;padding:2px 6px;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
.mc .chip:hover{background:#F0F4F9}
.mc .chip .cdot{width:8px;height:8px;border-radius:50%;flex:0 0 auto}
.mc .more{font-size:10.5px;color:var(--muted);margin-top:3px;font-weight:600;cursor:pointer}
.mc-empty{grid-column:1 / -1;text-align:center;padding:30px 16px;color:var(--muted);font-size:13px}
/* vista lista */
.cal-list-card{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;box-shadow:var(--shadow)}
.cal-list{width:100%;border-collapse:collapse;font-size:13px}
.cal-list th,.cal-list td{text-align:left;padding:10px 14px;border-top:1px solid var(--line);vertical-align:middle}
.cal-list thead th{border-top:0;font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:#8093A8;font-weight:700;background:#F6F9FC}
.cal-list tr.row-overdue td{background:#FEF2F2}
.cal-list a.cal-link{color:var(--c-blue);font-weight:600}
.cal-list a.cal-link:hover{text-decoration:underline}
.type-tag{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600;padding:3px 9px;border-radius:6px}
.type-tag .cdot{width:8px;height:8px;border-radius:50%}
.st-badge{display:inline-block;font-size:10px;font-weight:700;padding:3px 9px;border-radius:6px;white-space:nowrap;color:#fff;text-transform:uppercase;letter-spacing:.03em}
.st-badge.st-overdue{background:#dc2626}.st-badge.st-due_soon{background:#d97706}
.st-badge.st-upcoming{background:#2563eb}.st-badge.st-done{background:#16a34a}
.cal-empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:13px}
.hidden{display:none !important}
/* popover giorno (modale) */
.cal-modal{position:fixed;inset:0;background:rgba(13,28,52,.45);display:none;align-items:flex-start;justify-content:center;z-index:90;padding:40px 16px;overflow:auto}
.cal-modal.open{display:flex}
.cal-modal-card{background:#fff;border-radius:14px;width:100%;max-width:520px;box-shadow:0 24px 60px rgba(11,30,60,.35)}
.cal-modal-head{display:flex;align-items:center;justify-content:space-between;padding:15px 18px;border-bottom:1px solid var(--line)}
.cal-modal-head h3{margin:0;font-size:15px;font-weight:700}
.cal-modal-x{border:0;background:transparent;cursor:pointer;color:var(--muted);width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:1.4rem;line-height:1}
.cal-modal-x:hover{background:#F0F4F9}
.cal-modal-body{padding:14px 18px}
.cal-pop-item{display:flex;gap:10px;align-items:flex-start;padding:9px 0;border-top:1px solid var(--line)}
.cal-pop-item:first-child{border-top:none}
.cal-pop-item .cdot{width:10px;height:10px;border-radius:50%;margin-top:4px;flex:0 0 auto}
@media (max-width:900px){
.cal-app{grid-template-columns:1fr}
.cal-side{flex-direction:row;flex-wrap:wrap}
.cal-kpis{grid-template-columns:repeat(2,1fr);flex:1;min-width:220px}
.legend{flex:1;min-width:220px}
.mc{min-height:84px}
}
</style>
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="cal.title">Calendario scadenze</h2>
</header>
<div class="content-body">
<div class="cal-intro">
<strong>Tutte le scadenze in un solo posto.</strong>
Il calendario aggrega <em>in tempo reale</em> ogni scadenza del sistema — incidenti, revisioni policy e controlli,
trattamenti di rischio, non conformita e azioni correttive, formazione, attivita stakeholder, scadenziario revisioni,
audit interni e decisioni del riesame — con stato e link diretto all'elemento.
</div>
<div class="cal-note">
Ancoraggio: buona prassi di monitoraggio scadenze e revisioni periodiche (<strong>ISO 9.1/9.3</strong>,
NIS2 <strong>GV.PO-02 / GV.SC-07 / DE.CM</strong>). Strumento di supporto operativo, non un parere legale.
</div>
<!-- KPI sintetici --> <main class="wrap">
<div class="cal-summary" id="cal-summary" aria-live="polite"></div> <nav class="crumb" aria-label="breadcrumb">
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="cal.title">Calendario</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="cal.title">Calendario scadenze</h1>
<p class="sub">Vista temporale aggregata di scadenze, revisioni e controlli del sistema</p>
</div>
</div>
<!-- Barra: navigazione mese + selettore vista --> <div class="cal-app">
<div class="cal-bar"> <!-- Sidebar: KPI + legenda/filtri categoria + stato -->
<div class="cal-nav"> <aside class="cal-side">
<button class="btn btn-outline-primary btn-sm" type="button" onclick="calPrevMonth()" aria-label="Mese precedente">&lsaquo;</button> <div class="cal-kpis" id="cal-summary" aria-live="polite"></div>
<h3 id="cal-month-label">—</h3>
<button class="btn btn-outline-primary btn-sm" type="button" onclick="calNextMonth()" aria-label="Mese successivo">&rsaquo;</button>
<button class="btn btn-outline-secondary btn-sm" type="button" onclick="calToday()">Oggi</button>
</div>
<div class="cal-views" role="tablist" aria-label="Vista calendario">
<button class="cal-view-btn active" id="view-grid" role="tab" aria-selected="true" aria-controls="pane-grid" onclick="calSetView('grid')">Griglia</button>
<button class="cal-view-btn" id="view-list" role="tab" aria-selected="false" aria-controls="pane-list" onclick="calSetView('list')">Lista</button>
</div>
</div>
<!-- Filtri --> <div class="legend">
<div class="cal-filters"> <p>Filtra per tipo</p>
<h4>Filtra per tipo</h4>
<div class="cal-chips" id="cal-type-chips"></div> <div class="cal-chips" id="cal-type-chips"></div>
<div class="cal-filter-row"> </div>
<h4>Filtra per stato</h4>
<div class="cal-chips" id="cal-status-chips"></div> <div class="legend">
<p>Filtra per stato</p>
<div class="cal-chips" id="cal-status-chips"></div>
</div>
</aside>
<!-- Main: toolbar + viste -->
<section class="cal-main">
<div class="cal-toolbar">
<div class="tb-left">
<button class="ctrl-today" id="tb-today" onclick="calToday()"><i data-lucide="calendar-check" class="ic"></i>Oggi</button>
<div class="tb-nav">
<button class="ctrl-btn" id="tb-prev" title="Mese precedente" aria-label="Mese precedente" onclick="calPrevMonth()"><i data-lucide="chevron-left" class="ic"></i></button>
<button class="ctrl-btn" id="tb-next" title="Mese successivo" aria-label="Mese successivo" onclick="calNextMonth()"><i data-lucide="chevron-right" class="ic"></i></button>
</div>
<span class="tb-title" id="cal-month-label">—</span>
</div>
<div class="view-switch" role="tablist" aria-label="Vista calendario">
<button data-v="grid" class="on" id="view-grid" role="tab" aria-selected="true" aria-controls="pane-grid" onclick="calSetView('grid')">Griglia</button>
<button data-v="list" id="view-list" role="tab" aria-selected="false" aria-controls="pane-list" onclick="calSetView('list')">Lista</button>
</div> </div>
</div> </div>
<!-- Vista griglia mensile --> <!-- Vista griglia mensile -->
<section id="pane-grid" role="tabpanel" aria-labelledby="view-grid" aria-live="polite"> <section id="pane-grid" class="mv" role="tabpanel" aria-labelledby="view-grid" aria-live="polite">
<table class="cal-grid"> <div class="mv-dow"><div>Lun</div><div>Mar</div><div>Mer</div><div>Gio</div><div>Ven</div><div>Sab</div><div>Dom</div></div>
<thead> <div class="mv-grid" id="mv-grid"></div>
<tr id="cal-weekhead"></tr>
</thead>
<tbody id="cal-grid-body"></tbody>
</table>
</section> </section>
<!-- Vista lista --> <!-- Vista lista -->
<section id="pane-list" class="hidden" role="tabpanel" aria-labelledby="view-list" aria-live="polite"> <section id="pane-list" class="hidden" role="tabpanel" aria-labelledby="view-list" aria-live="polite">
<div id="cal-list-wrap"></div> <div class="cal-list-card" id="cal-list-wrap"></div>
</section> </section>
</div> </section>
</main> </div>
</div> </main>
<!-- Popover eventi di un giorno --> <!-- Popover eventi di un giorno (modale) -->
<div class="cal-pop" id="cal-pop" role="dialog" aria-modal="true" aria-labelledby="cal-pop-title"> <div class="cal-modal" id="cal-pop" role="dialog" aria-modal="true" aria-labelledby="cal-pop-title">
<div class="cal-pop-card"> <div class="cal-modal-card">
<div class="cal-pop-head"> <div class="cal-modal-head">
<h3 id="cal-pop-title">Eventi</h3> <h3 id="cal-pop-title">Eventi</h3>
<button class="cal-pop-close" type="button" aria-label="Chiudi" onclick="calPopClose()">&times;</button> <button class="cal-modal-x" type="button" aria-label="Chiudi" onclick="calPopClose()">&times;</button>
</div> </div>
<div class="cal-pop-body" id="cal-pop-body"></div> <div class="cal-modal-body" id="cal-pop-body"></div>
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="/js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help. La logica del
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> calendario (aggregatore read-only) resta inline e usa gli stessi id/funzioni. -->
<script src="/js/common-bi.js?v=20260631"></script> <script src="/js/api.js?v=20260730a"></script>
<script src="/js/i18n.js?v=20260631"></script> <script src="/js/common.js?v=20260729b"></script>
<script src="/js/help.js?v=20260618p2"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
'use strict'; 'use strict';
/* /*
* NIS2 Agile - Calendario unico delle scadenze (Modulo C) * NIS2 Agile - Calendario unico delle scadenze (Modulo C) — UI V3
* Aggregatore READ-ONLY: griglia mensile + lista + filtri (tipo/stato) + deep-link. * Aggregatore READ-ONLY: griglia mensile + lista + filtri (tipo/stato) + deep-link.
* Dependency-free: nessuna libreria calendario, render in JS puro. * Dati reali via api.calendarEvents / api.calendarSummary. Dependency-free.
* Veste fedele a docs/mockup Ui/calendario.html (.cal-app/.mv-grid/.view-switch/.legend).
*/ */
var CAL = { var CAL = {
view: 'grid', view: 'grid',
@@ -206,12 +246,14 @@
var day = String(d.getDate()).padStart(2, '0'); var day = String(d.getDate()).padStart(2, '0');
return d.getFullYear() + '-' + m + '-' + day; return d.getFullYear() + '-' + m + '-' + day;
} }
function refreshIcons() { if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} } }
document.addEventListener('DOMContentLoaded', async function () { document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return; if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it'); if (typeof loadSidebar === 'function') loadSidebar(); // = loadTopnavV3()
if (typeof loadSidebar === 'function') loadSidebar(); if (window.I18n && I18n.init) I18n.init();
if (window.HelpSystem && HelpSystem.init) HelpSystem.init(); if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
refreshIcons();
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') calPopClose(); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') calPopClose(); });
var pop = el('cal-pop'); if (pop) pop.addEventListener('click', function (e) { if (e.target === pop) calPopClose(); }); var pop = el('cal-pop'); if (pop) pop.addEventListener('click', function (e) { if (e.target === pop) calPopClose(); });
CAL.cursor = new Date(); CAL.cursor.setDate(1); CAL.cursor = new Date(); CAL.cursor.setDate(1);
@@ -224,13 +266,13 @@
var from = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() - 2, 1); var from = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() - 2, 1);
var to = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + 3, 0); var to = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + 3, 0);
CAL.rangeFrom = ymd(from); CAL.rangeTo = ymd(to); CAL.rangeFrom = ymd(from); CAL.rangeTo = ymd(to);
el('cal-grid-body').innerHTML = '<tr><td colspan="7" class="cal-empty">Caricamento…</td></tr>'; el('mv-grid').innerHTML = '<div class="mc-empty"><span class="v3-loading"><span class="v3-spin"></span>Caricamento…</span></div>';
try { try {
var data = await api.calendarEvents({ from: CAL.rangeFrom, to: CAL.rangeTo }); var data = await api.calendarEvents({ from: CAL.rangeFrom, to: CAL.rangeTo });
CAL.events = (data && data.events) ? data.events : []; CAL.events = (data && data.events) ? data.events : [];
} catch (e) { } catch (e) {
CAL.events = []; CAL.events = [];
el('cal-grid-body').innerHTML = '<tr><td colspan="7" class="cal-empty">Errore nel caricamento: ' + esc(e.message || '') + '</td></tr>'; el('mv-grid').innerHTML = '<div class="mc-empty">Errore nel caricamento: ' + esc(e.message || '') + '</div>';
} }
await loadSummary(); await loadSummary();
render(); render();
@@ -242,7 +284,7 @@
var bs = (s && s.by_status) || {}; var bs = (s && s.by_status) || {};
var defs = [['overdue', 'In ritardo'], ['due_soon', 'In scadenza'], ['upcoming', 'Future'], ['done', 'Assolte']]; var defs = [['overdue', 'In ritardo'], ['due_soon', 'In scadenza'], ['upcoming', 'Future'], ['done', 'Assolte']];
el('cal-summary').innerHTML = defs.map(function (d) { el('cal-summary').innerHTML = defs.map(function (d) {
return '<div class="cal-kpi k-' + d[0] + '"><div class="n">' + (bs[d[0]] || 0) + '</div><div class="l">' + d[1] + '</div></div>'; return '<div class="cal-kpi k-' + d[0] + '"><div class="l">' + d[1] + '</div><div class="n">' + (bs[d[0]] || 0) + '</div></div>';
}).join(''); }).join('');
} catch (e) { el('cal-summary').innerHTML = ''; } } catch (e) { el('cal-summary').innerHTML = ''; }
} }
@@ -252,12 +294,12 @@
el('cal-type-chips').innerHTML = Object.keys(TYPE_META).map(function (t) { el('cal-type-chips').innerHTML = Object.keys(TYPE_META).map(function (t) {
var m = TYPE_META[t]; var m = TYPE_META[t];
return '<label class="cal-chip"><input type="checkbox" class="f-type" value="' + escAttr(t) + '" checked onchange="onTypeFilter()">' + return '<label class="cal-chip"><input type="checkbox" class="f-type" value="' + escAttr(t) + '" checked onchange="onTypeFilter()">' +
'<span class="dot" style="background:' + m.color + '"></span>' + esc(m.label) + '</label>'; '<span class="dot" style="background:' + m.color + '"></span><span class="lbl">' + esc(m.label) + '</span></label>';
}).join(''); }).join('');
el('cal-status-chips').innerHTML = Object.keys(STATUS_META).map(function (s) { el('cal-status-chips').innerHTML = Object.keys(STATUS_META).map(function (s) {
var m = STATUS_META[s]; var m = STATUS_META[s];
return '<label class="cal-chip"><input type="checkbox" class="f-status" value="' + escAttr(s) + '" checked onchange="onStatusFilter()">' + return '<label class="cal-chip"><input type="checkbox" class="f-status" value="' + escAttr(s) + '" checked onchange="onStatusFilter()">' +
'<span class="dot" style="background:' + m.color + '"></span>' + esc(m.label) + '</label>'; '<span class="dot" style="background:' + m.color + '"></span><span class="lbl">' + esc(m.label) + '</span></label>';
}).join(''); }).join('');
} }
function onTypeFilter() { function onTypeFilter() {
@@ -294,8 +336,8 @@
} }
function calSetView(v) { function calSetView(v) {
CAL.view = v; CAL.view = v;
el('view-grid').classList.toggle('active', v === 'grid'); el('view-grid').classList.toggle('on', v === 'grid');
el('view-list').classList.toggle('active', v === 'list'); el('view-list').classList.toggle('on', v === 'list');
el('view-grid').setAttribute('aria-selected', v === 'grid' ? 'true' : 'false'); el('view-grid').setAttribute('aria-selected', v === 'grid' ? 'true' : 'false');
el('view-list').setAttribute('aria-selected', v === 'list' ? 'true' : 'false'); el('view-list').setAttribute('aria-selected', v === 'list' ? 'true' : 'false');
el('pane-grid').classList.toggle('hidden', v !== 'grid'); el('pane-grid').classList.toggle('hidden', v !== 'grid');
@@ -308,10 +350,8 @@
if (CAL.view === 'grid') renderGrid(); else renderList(); if (CAL.view === 'grid') renderGrid(); else renderList();
} }
// ── Vista griglia mensile ── // ── Vista griglia mensile (.mv-grid del mockup) ──
function renderGrid() { function renderGrid() {
el('cal-weekhead').innerHTML = WEEKDAYS.map(function (w) { return '<th>' + w + '</th>'; }).join('');
var year = CAL.cursor.getFullYear(), month = CAL.cursor.getMonth(); var year = CAL.cursor.getFullYear(), month = CAL.cursor.getMonth();
var firstOfMonth = new Date(year, month, 1); var firstOfMonth = new Date(year, month, 1);
// settimana inizia lunedi (getDay: 0=dom) // settimana inizia lunedi (getDay: 0=dom)
@@ -324,32 +364,31 @@
visibleEvents().forEach(function (e) { (byDay[e.date] = byDay[e.date] || []).push(e); }); visibleEvents().forEach(function (e) { (byDay[e.date] = byDay[e.date] || []).push(e); });
var html = ''; var html = '';
for (var w = 0; w < 6; w++) { for (var i = 0; i < 42; i++) {
html += '<tr>'; var cur = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + i);
for (var d = 0; d < 7; d++) { var key = ymd(cur);
var cur = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + (w * 7 + d)); var inMonth = (cur.getMonth() === month);
var key = ymd(cur); var evs = byDay[key] || [];
var inMonth = (cur.getMonth() === month); var cls = 'mc' + (inMonth ? '' : ' other') + (key === todayStr ? ' today' : '') + (evs.length ? ' has' : '');
var cls = inMonth ? '' : ' cal-out'; var chips = '';
if (key === todayStr) cls += ' cal-today'; evs.slice(0, 3).forEach(function (e) {
var evs = byDay[key] || []; var c = (TYPE_META[e.type] || {}).color || '#64748b';
var dots = ''; var tip = escAttr((TYPE_META[e.type] || { label: e.type }).label + ' — ' + e.title);
var shown = evs.slice(0, 6); chips += '<div class="chip" title="' + tip + '"><span class="cdot" style="background:' + c + '"></span>' + esc(e.title) + '</div>';
shown.forEach(function (e) { });
var c = (TYPE_META[e.type] || {}).color || '#64748b'; var more = evs.length > 3 ? '<div class="more">+' + (evs.length - 3) + ' altri</div>' : '';
var tip = escAttr((TYPE_META[e.type] || { label: e.type }).label + ' — ' + e.title); var click = evs.length ? ' role="button" tabindex="0" aria-label="' + escAttr(cur.getDate() + ': ' + evs.length + ' scadenze') + '" data-day="' + key + '"' : '';
dots += '<span class="cal-dot st-' + esc(e.status) + '" style="background:' + c + '" title="' + tip + '" onclick="calPopDay(\'' + key + '\')"></span>'; html += '<div class="' + cls + '"' + click + '>' +
}); '<div class="dnum">' + cur.getDate() + '</div>' + chips + more + '</div>';
var more = evs.length > shown.length ? '<div class="cal-more" onclick="calPopDay(\'' + key + '\')">+' + (evs.length - shown.length) + ' altri</div>' : '';
var clickDay = evs.length ? ' style="cursor:pointer" role="button" tabindex="0" aria-label="' + escAttr(cur.getDate() + ': ' + evs.length + ' scadenze') + '" onclick="calPopDay(\'' + key + '\')" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();calPopDay(\'' + key + '\');}"' : '';
html += '<td class="' + cls + '">' +
'<div class="cal-daynum"' + clickDay + '>' + cur.getDate() + '</div>' +
(evs.length ? '<div class="cal-day-dots">' + dots + '</div>' + more : '') +
'</td>';
}
html += '</tr>';
} }
el('cal-grid-body').innerHTML = html; var grid = el('mv-grid');
grid.innerHTML = html;
// wiring click/keyboard sui giorni con eventi
grid.querySelectorAll('.mc.has').forEach(function (cell) {
var key = cell.getAttribute('data-day');
cell.addEventListener('click', function () { calPopDay(key); });
cell.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); calPopDay(key); } });
});
} }
// ── Vista lista (overdue in cima, poi per data) ── // ── Vista lista (overdue in cima, poi per data) ──
@@ -364,8 +403,8 @@
var m = TYPE_META[e.type] || { label: e.type, color: '#64748b' }; var m = TYPE_META[e.type] || { label: e.type, color: '#64748b' };
var sm = STATUS_META[e.status] || { label: e.status }; var sm = STATUS_META[e.status] || { label: e.status };
return '<tr class="row-' + esc(e.status) + '">' + return '<tr class="row-' + esc(e.status) + '">' +
'<td>' + esc(formatDate(e.date)) + '</td>' + '<td>' + esc(calFmtDate(e.date)) + '</td>' +
'<td><span class="type-tag" style="background:' + m.color + '22;color:' + m.color + '">' + esc(m.label) + '</span></td>' + '<td><span class="type-tag" style="background:' + m.color + '22;color:' + m.color + '"><span class="cdot" style="background:' + m.color + '"></span>' + esc(m.label) + '</span></td>' +
'<td><a class="cal-link" href="' + escAttr(e.link || '#') + '">' + esc(e.title) + '</a></td>' + '<td><a class="cal-link" href="' + escAttr(e.link || '#') + '">' + esc(e.title) + '</a></td>' +
'<td><span class="st-badge st-' + esc(e.status) + '">' + esc(sm.label) + '</span></td>' + '<td><span class="st-badge st-' + esc(e.status) + '">' + esc(sm.label) + '</span></td>' +
'</tr>'; '</tr>';
@@ -374,7 +413,7 @@
'<table class="cal-list"><thead><tr><th>Data</th><th>Tipo</th><th>Elemento</th><th>Stato</th></tr></thead><tbody>' + rows + '</tbody></table>'; '<table class="cal-list"><thead><tr><th>Data</th><th>Tipo</th><th>Elemento</th><th>Stato</th></tr></thead><tbody>' + rows + '</tbody></table>';
} }
function formatDate(s) { function calFmtDate(s) {
var p = String(s).split('-'); var p = String(s).split('-');
if (p.length !== 3) return s; if (p.length !== 3) return s;
return p[2] + '/' + p[1] + '/' + p[0]; return p[2] + '/' + p[1] + '/' + p[0];
@@ -384,15 +423,15 @@
function calPopDay(dateKey) { function calPopDay(dateKey) {
var evs = visibleEvents().filter(function (e) { return e.date === dateKey; }); var evs = visibleEvents().filter(function (e) { return e.date === dateKey; });
if (!evs.length) return; if (!evs.length) return;
el('cal-pop-title').textContent = 'Scadenze del ' + formatDate(dateKey); el('cal-pop-title').textContent = 'Scadenze del ' + calFmtDate(dateKey);
el('cal-pop-body').innerHTML = evs.map(function (e) { el('cal-pop-body').innerHTML = evs.map(function (e) {
var m = TYPE_META[e.type] || { label: e.type, color: '#64748b' }; var m = TYPE_META[e.type] || { label: e.type, color: '#64748b' };
var sm = STATUS_META[e.status] || { label: e.status }; var sm = STATUS_META[e.status] || { label: e.status };
return '<div class="cal-pop-item">' + return '<div class="cal-pop-item">' +
'<span class="cal-dot st-' + esc(e.status) + '" style="background:' + m.color + ';margin-top:4px;flex:0 0 auto"></span>' + '<span class="cdot" style="background:' + m.color + '"></span>' +
'<div style="flex:1 1 auto">' + '<div style="flex:1 1 auto;min-width:0">' +
'<div><a class="cal-link" href="' + escAttr(e.link || '#') + '">' + esc(e.title) + '</a></div>' + '<div><a class="cal-link" href="' + escAttr(e.link || '#') + '">' + esc(e.title) + '</a></div>' +
'<div style="font-size:.78rem;color:#6b7280;margin-top:2px">' + esc(m.label) + '<div style="font-size:.8rem;color:var(--muted);margin-top:3px">' + esc(m.label) +
' · <span class="st-badge st-' + esc(e.status) + '">' + esc(sm.label) + '</span></div>' + ' · <span class="st-badge st-' + esc(e.status) + '">' + esc(sm.label) + '</span></div>' +
'</div></div>'; '</div></div>';
}).join(''); }).join('');
+345 -416
View File
@@ -3,270 +3,88 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Le Mie Aziende - NIS2 Agile</title> <title>Cruscotto Studio · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (MAI CDN). Caricato PRIMA di style.css: BI veste la chrome/sidebar, style.css resta autorevole per i widget. --> <!-- UI V3: Inter self-host + design system v3.css (NO Bootstrap Italia / NO CDN). -->
<link rel="stylesheet" href="vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="css/style.css?v=20260618"> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<style> <style>
.companies-header { /* ── Barra KPI (griglia 4 col responsive, riusa .stat di v3.css) ── */
display: flex; .kpi-grid { display:grid; grid-template-columns:repeat(4, 1fr); gap:18px; margin-bottom:22px; }
align-items: center; @media (max-width:900px){ .kpi-grid{ grid-template-columns:repeat(2,1fr); } }
justify-content: space-between; @media (max-width:520px){ .kpi-grid{ grid-template-columns:1fr; } }
gap: 16px;
margin-bottom: 28px; /* ── Card aziende (non coperte da v3.css; nessun CDN) ───────────── */
flex-wrap: wrap; .companies-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:18px; }
.search-box { position:relative; min-width:240px; flex:1; max-width:340px; }
.search-box svg { position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--muted); pointer-events:none; }
.search-box input { width:100%; padding:9px 12px 9px 38px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:13px; background:#fff; color:var(--ink); outline:none; }
.search-box input:focus { border-color:#9FBEDD; }
.companies-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(300px, 1fr)); gap:18px; }
.company-card { background:var(--card); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:20px; transition:box-shadow .12s, transform .12s; display:flex; flex-direction:column; gap:16px; cursor:pointer; }
.company-card:hover { box-shadow:0 8px 20px rgba(16,40,80,.13); border-color:#9FBEDD; transform:translateY(-1px); }
.company-card-header { display:flex; align-items:flex-start; gap:14px; }
.company-card-avatar { width:48px; height:48px; border-radius:11px; background:linear-gradient(135deg,#143A5C,#1F5FA8); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:800; font-size:1.05rem; flex-shrink:0; }
.company-card-info { flex:1; min-width:0; }
.company-card-name { font-size:1rem; font-weight:800; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:4px; }
.company-card-vat { font-size:.8125rem; color:var(--muted); font-family:'Inter',monospace; }
.entity-badge { display:inline-flex; align-items:center; gap:5px; padding:3px 9px; border-radius:6px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; white-space:nowrap; }
.entity-badge.essential { background:#FBE9E9; color:#B23636; }
.entity-badge.important { background:#FBF1E2; color:#9A6418; }
.entity-badge.not_applicable, .entity-badge.voluntary { background:#EEF2F7; color:#5b6b80; }
.overdue-badge { display:inline-flex; align-items:center; gap:5px; padding:3px 9px; border-radius:6px; font-size:11px; font-weight:700; background:#FBE9E9; color:#B23636; white-space:nowrap; }
.company-score-row { display:flex; align-items:center; gap:12px; }
.company-score-label { font-size:.8125rem; color:var(--muted); white-space:nowrap; }
.company-score-bar { flex:1; height:7px; background:#EAF0F6; border-radius:99px; overflow:hidden; }
.company-score-bar-fill { height:100%; border-radius:99px; transition:width .6s ease; }
.company-score-value { font-size:.875rem; font-weight:700; min-width:36px; text-align:right; }
.company-stats { display:flex; gap:16px; align-items:center; }
.company-stat { display:flex; flex-direction:column; gap:2px; }
.company-stat-value { font-size:1.125rem; font-weight:800; color:var(--ink); }
.company-stat-label { font-size:.75rem; color:var(--muted); }
.company-card-footer { border-top:1px solid var(--line); padding-top:16px; display:flex; align-items:center; justify-content:space-between; gap:8px; }
.company-card-sector { font-size:.8125rem; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1; }
.btn-sm { padding:6px 11px; font-size:11.5px; }
.companies-empty { text-align:center; padding:60px 24px; color:var(--muted); grid-column:1 / -1; }
.companies-empty svg { width:56px; height:56px; color:var(--line); margin:0 auto 18px; display:block; }
.companies-empty h3 { font-size:1.05rem; font-weight:700; color:var(--ink); margin-bottom:8px; }
.companies-empty p { font-size:.9375rem; margin-bottom:22px; line-height:1.6; }
/* Skeleton loader */
.skeleton { background:linear-gradient(90deg,#EEF2F7 25%,#F6F9FC 50%,#EEF2F7 75%); background-size:200% 100%; animation:shimmer 1.5s infinite; border-radius:6px; }
@keyframes shimmer { 0%{background-position:200% 0;} 100%{background-position:-200% 0;} }
@media (prefers-reduced-motion:reduce) { .skeleton{animation:none;} }
.skeleton-card { background:var(--card); border:1px solid var(--line); border-radius:var(--radius); padding:20px; display:flex; flex-direction:column; gap:16px; }
/* Sezione scadenze generali */
.deadlines-wrap { max-height:520px; overflow-y:auto; }
.deadline-row { display:grid; grid-template-columns:1.4fr 1.8fr 1.1fr .9fr auto; gap:14px; align-items:center; padding:11px 4px; border-bottom:1px solid var(--line); font-size:13px; }
.deadline-row:last-child { border-bottom:0; }
.deadline-org { font-weight:700; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.deadline-title { color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.deadline-type { color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.deadline-date { color:var(--muted); font-variant-numeric:tabular-nums; white-space:nowrap; }
.deadline-status { justify-self:end; }
.dl-badge { display:inline-flex; align-items:center; padding:2px 9px; border-radius:99px; font-size:11px; font-weight:700; white-space:nowrap; }
.dl-badge.overdue { background:#FBE9E9; color:#B23636; }
.dl-badge.soon { background:#FBF1E2; color:#9A6418; }
.dl-badge.ok { background:#EAF1FA; color:#1F5FA8; }
.deadlines-empty { text-align:center; color:var(--muted); padding:36px 12px; font-size:14px; }
@media (max-width:720px){
.deadline-row { grid-template-columns:1fr auto; grid-row-gap:2px; }
.deadline-type, .deadline-date { display:none; }
} }
.companies-header h1 { /* Modale connettori (reskin sobrio, nessun CDN) */
font-size: 1.5rem; .conn-row { border:1px solid var(--line); border-radius:8px; padding:12px; margin-bottom:10px; }
font-weight: 700; .conn-row input { padding:7px 9px; border:1px solid var(--line); border-radius:6px; font:inherit; font-size:13px; outline:none; }
color: var(--gray-900); .conn-row input:focus { border-color:#9FBEDD; }
letter-spacing: -0.01em;
}
.companies-header-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search-box {
position: relative;
min-width: 240px;
}
.search-box svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--gray-400);
pointer-events: none;
}
.search-box input {
padding-left: 38px;
}
/* ── Company Cards Grid ─────────────────────────────────────── */
.companies-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.company-card {
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius-lg);
padding: 24px;
transition: all var(--transition);
display: flex;
flex-direction: column;
gap: 16px;
}
.company-card:hover {
box-shadow: var(--card-shadow-hover);
border-color: var(--primary-light);
transform: translateY(-2px);
}
.company-card-header {
display: flex;
align-items: flex-start;
gap: 14px;
}
.company-card-avatar {
width: 48px;
height: 48px;
border-radius: var(--border-radius);
background: var(--primary-bg);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.1rem;
flex-shrink: 0;
}
.company-card-info {
flex: 1;
min-width: 0;
}
.company-card-name {
font-size: 1rem;
font-weight: 700;
color: var(--gray-900);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.company-card-vat {
font-size: 0.8125rem;
color: var(--gray-500);
font-family: monospace;
}
.entity-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 99px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.entity-badge.essential {
background: rgba(234, 67, 53, 0.1);
color: #c5221f;
}
.entity-badge.important {
background: rgba(251, 188, 4, 0.15);
color: #b06000;
}
.entity-badge.not_applicable,
.entity-badge.voluntary {
background: var(--gray-100);
color: var(--gray-500);
}
/* ── Score Bar ───────────────────────────────────────────────── */
.company-score-row {
display: flex;
align-items: center;
gap: 12px;
}
.company-score-label {
font-size: 0.8125rem;
color: var(--gray-500);
white-space: nowrap;
}
.company-score-bar {
flex: 1;
height: 6px;
background: var(--gray-100);
border-radius: 99px;
overflow: hidden;
}
.company-score-bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.6s ease;
}
.company-score-value {
font-size: 0.875rem;
font-weight: 700;
min-width: 36px;
text-align: right;
}
/* ── Stats Row ───────────────────────────────────────────────── */
.company-stats {
display: flex;
gap: 16px;
}
.company-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.company-stat-value {
font-size: 1.125rem;
font-weight: 700;
color: var(--gray-900);
}
.company-stat-label {
font-size: 0.75rem;
color: var(--gray-500);
}
/* ── Card Footer ─────────────────────────────────────────────── */
.company-card-footer {
border-top: 1px solid var(--gray-100);
padding-top: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.company-card-sector {
font-size: 0.8125rem;
color: var(--gray-500);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Empty State ─────────────────────────────────────────────── */
.companies-empty {
text-align: center;
padding: 80px 24px;
color: var(--gray-500);
}
.companies-empty svg {
width: 64px;
height: 64px;
color: var(--gray-300);
margin: 0 auto 20px;
display: block;
}
.companies-empty h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 8px;
}
.companies-empty p {
font-size: 0.9375rem;
margin-bottom: 24px;
line-height: 1.6;
}
/* ── Loading skeleton ────────────────────────────────────────── */
.skeleton {
background: linear-gradient(90deg, var(--gray-100) 25%, var(--gray-50) 50%, var(--gray-100) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 6px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-card {
background: var(--card-bg);
border: 1px solid var(--gray-200);
border-radius: var(--border-radius-lg);
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -283,188 +101,338 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<!-- Sidebar --> <div id="topnav"></div>
<aside id="sidebar" class="sidebar"></aside>
<!-- Main Content -->
<main class="main-content">
<div class="page-container">
<!-- Page Header -->
<div class="companies-header" id="nis2-companies-header">
<div>
<h1>Le Mie Aziende</h1>
<p style="color:var(--gray-500);font-size:.9rem;margin-top:2px;">Gestisci il portafoglio clienti e monitora la compliance NIS2</p>
</div>
<div class="companies-header-actions">
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
<input type="text" class="form-input" id="search-input" placeholder="Cerca azienda..." oninput="filterCompanies(this.value)">
</div>
<a href="onboarding.html" class="btn btn-primary" id="nis2-companies-add-btn">
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Aggiungi Cliente
</a>
</div>
</div>
<!-- Companies Grid -->
<div class="companies-grid" id="companies-grid">
<!-- Skeleton loaders -->
<div class="skeleton-card">
<div style="display:flex;gap:12px;align-items:center">
<div class="skeleton" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div>
<div style="flex:1"><div class="skeleton" style="height:16px;margin-bottom:8px"></div><div class="skeleton" style="height:12px;width:60%"></div></div>
</div>
<div class="skeleton" style="height:6px"></div>
<div style="display:flex;gap:16px"><div class="skeleton" style="height:40px;flex:1"></div><div class="skeleton" style="height:40px;flex:1"></div></div>
<div class="skeleton" style="height:36px"></div>
</div>
<div class="skeleton-card">
<div style="display:flex;gap:12px;align-items:center">
<div class="skeleton" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div>
<div style="flex:1"><div class="skeleton" style="height:16px;margin-bottom:8px"></div><div class="skeleton" style="height:12px;width:60%"></div></div>
</div>
<div class="skeleton" style="height:6px"></div>
<div style="display:flex;gap:16px"><div class="skeleton" style="height:40px;flex:1"></div><div class="skeleton" style="height:40px;flex:1"></div></div>
<div class="skeleton" style="height:36px"></div>
</div>
</div>
<!-- Empty state (hidden initially) -->
<div class="companies-empty" id="companies-empty" style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z"/>
</svg>
<h3>Nessuna azienda ancora</h3>
<p>Aggiungi il tuo primo cliente per iniziare a monitorare la compliance NIS2.</p>
<a href="onboarding.html" class="btn btn-primary" id="nis2-companies-add-first-btn">
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Aggiungi Primo Cliente
</a>
</div>
<main class="wrap">
<nav class="crumb" aria-label="breadcrumb">
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<i data-lucide="chevron-right" class="ic"></i><span>Cruscotto Studio</span>
</nav>
<div class="page-head" id="nis2-companies-header">
<div>
<h1 data-i18n="companies.title">Cruscotto Studio</h1>
<p class="sub" data-i18n="companies.subtitle">Portafoglio clienti, compliance e scadenze in un colpo d'occhio</p>
</div> </div>
</main> <a href="onboarding.html" class="btn-primary" id="nis2-companies-add-btn">
</div> <i data-lucide="plus" class="ic"></i>Aggiungi Cliente
</a>
</div>
<script src="js/api.js?v=20260631"></script> <!-- ── Barra KPI ────────────────────────────────────────────── -->
<script src="js/common.js?v=20260631"></script> <div class="kpi-grid" id="kpi-grid">
<!-- Bootstrap Italia bundle + override sidebar BI (common-bi.js dopo common.js: ridefinisce solo loadSidebar) --> <div class="card card-pad stat blue" id="kpi-total">
<script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <div class="ibox"><i data-lucide="building-2" class="ic"></i></div>
<script>if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }</script> <div style="flex:1;min-width:0"><div class="lbl" data-i18n="companies.kpi_total">Aziende gestite</div><div class="val">…</div></div>
<script src="js/common-bi.js?v=20260631"></script> </div>
<script src="js/i18n.js?v=20260631"></script> <div class="card card-pad stat green" id="kpi-avg">
<script src="js/help.js?v=20260618p2"></script> <div class="ibox"><i data-lucide="gauge" class="ic"></i></div>
<div style="flex:1;min-width:0"><div class="lbl" data-i18n="companies.kpi_avg">Compliance media</div><div class="val">…</div></div>
</div>
<div class="card card-pad stat amber" id="kpi-overdue">
<div class="ibox"><i data-lucide="alarm-clock-off" class="ic"></i></div>
<div style="flex:1;min-width:0"><div class="lbl" data-i18n="companies.kpi_overdue">Scadenze in ritardo</div><div class="val">…</div></div>
</div>
<div class="card card-pad stat blue" id="kpi-upcoming">
<div class="ibox"><i data-lucide="calendar-clock" class="ic"></i></div>
<div style="flex:1;min-width:0"><div class="lbl" data-i18n="companies.kpi_upcoming">Scadenze in arrivo</div><div class="val">…</div></div>
</div>
</div>
<!-- ── Griglia aziende ──────────────────────────────────────── -->
<section class="card card-pad">
<div class="companies-toolbar">
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
<input type="text" id="search-input" placeholder="Cerca azienda..." oninput="filterCompanies(this.value)" aria-label="Cerca azienda">
</div>
</div>
<!-- Companies Grid -->
<div class="companies-grid" id="companies-grid">
<!-- Skeleton loaders -->
<div class="skeleton-card">
<div style="display:flex;gap:12px;align-items:center">
<div class="skeleton" style="width:48px;height:48px;border-radius:11px;flex-shrink:0"></div>
<div style="flex:1"><div class="skeleton" style="height:16px;margin-bottom:8px"></div><div class="skeleton" style="height:12px;width:60%"></div></div>
</div>
<div class="skeleton" style="height:7px"></div>
<div style="display:flex;gap:16px"><div class="skeleton" style="height:40px;flex:1"></div><div class="skeleton" style="height:40px;flex:1"></div></div>
<div class="skeleton" style="height:36px"></div>
</div>
<div class="skeleton-card">
<div style="display:flex;gap:12px;align-items:center">
<div class="skeleton" style="width:48px;height:48px;border-radius:11px;flex-shrink:0"></div>
<div style="flex:1"><div class="skeleton" style="height:16px;margin-bottom:8px"></div><div class="skeleton" style="height:12px;width:60%"></div></div>
</div>
<div class="skeleton" style="height:7px"></div>
<div style="display:flex;gap:16px"><div class="skeleton" style="height:40px;flex:1"></div><div class="skeleton" style="height:40px;flex:1"></div></div>
<div class="skeleton" style="height:36px"></div>
</div>
</div>
<!-- Empty state (hidden initially) -->
<div class="companies-empty" id="companies-empty" style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z"/>
</svg>
<h3>Nessuna azienda ancora</h3>
<p>Aggiungi il tuo primo cliente per iniziare a monitorare la compliance NIS2.</p>
<a href="onboarding.html" class="btn-primary" id="nis2-companies-add-first-btn" style="display:inline-flex">
<i data-lucide="plus" class="ic"></i>Aggiungi Primo Cliente
</a>
</div>
</section>
<!-- ── Scadenze generali (tutte le aziende) ─────────────────── -->
<section class="card card-pad" style="margin-top:22px">
<div class="sec-head">
<h3 data-i18n="companies.deadlines_title">Scadenze generali (tutte le aziende)</h3>
<span class="hint" id="deadlines-hint"></span>
</div>
<div class="deadlines-wrap" id="deadlines-wrap">
<div class="deadlines-empty"><span class="v3-spin"></span> Caricamento…</div>
</div>
</section>
</main>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide → topnav-v3.js (chrome) → i18n → help → init pagina -->
<script src="/js/api.js?v=20260730a"></script>
<script src="/js/common.js?v=20260729b"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
// Check that user is consultant, else redirect // Accesso riservato a consulenti / super_admin (menu gated + backend gate).
if (!api.isConsultant()) { var _role = api.getUserRole();
if (['consultant', 'super_admin'].indexOf(_role) === -1) {
window.location.href = 'dashboard.html'; window.location.href = 'dashboard.html';
throw new Error('Not consultant'); throw new Error('Not consultant');
} }
loadSidebar(); loadSidebar();
if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init(); if (typeof HelpSystem !== 'undefined') HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
// ── Data ──────────────────────────────────────────────────────── // ── Data ────────────────────────────────────────────────────────
let allCompanies = []; let allCompanies = [];
// ── Load companies ─────────────────────────────────────────────── // Etichette leggibili tipi scadenza (dal contratto backend).
async function loadCompanies() { const DEADLINE_TYPE_LABELS = {
const result = await api.listOrganizations(); incident_early_warning: 'Early Warning',
incident_notification: 'Notifica CSIRT',
incident_final_report: 'Report finale',
policy_review: 'Revisione policy',
risk_treatment: 'Trattamento rischio',
training_due: 'Formazione'
};
// Colore barra compliance: <50 rosso · 50-74 giallo · ≥75 verde.
function complianceColor(score) {
if (score === null || score === undefined) return '#C7D2DE';
if (score < 50) return 'var(--c-red)';
if (score < 75) return 'var(--c-amber)';
return 'var(--c-green)';
}
// ── Load portfolio (una sola chiamata) ───────────────────────────
async function loadPortfolio() {
const grid = document.getElementById('companies-grid'); const grid = document.getElementById('companies-grid');
const emptyState = document.getElementById('companies-empty'); const emptyState = document.getElementById('companies-empty');
const dlWrap = document.getElementById('deadlines-wrap');
if (!result.success) { const result = await api.consultantPortfolio();
showNotification('Errore nel caricamento delle aziende.', 'error');
if (!result || !result.success) {
if (typeof showNotification === 'function') showNotification('Errore nel caricamento del cruscotto.', 'error');
grid.innerHTML = ''; grid.innerHTML = '';
emptyState.style.display = 'block'; emptyState.style.display = 'block';
dlWrap.innerHTML = '<div class="deadlines-empty">Impossibile caricare le scadenze.</div>';
renderKpis(null);
return; return;
} }
const orgs = result.data || []; const d = result.data || {};
allCompanies = orgs; renderKpis(d.kpis || {});
if (orgs.length === 0) { allCompanies = d.companies || [];
if (allCompanies.length === 0) {
grid.innerHTML = ''; grid.innerHTML = '';
emptyState.style.display = 'block'; emptyState.style.display = 'block';
return; } else {
emptyState.style.display = 'none';
renderCompanies(allCompanies);
} }
emptyState.style.display = 'none'; renderDeadlines(d.deadlines || []);
renderCompanies(orgs); }
// Load compliance scores in background // ── KPI bar ───────────────────────────────────────────────────────
orgs.forEach(org => loadOrgScore(org.id || org.organization_id)); function renderKpis(k) {
k = k || {};
const setVal = (id, txt) => { const el = document.querySelector('#' + id + ' .val'); if (el) el.textContent = txt; };
setVal('kpi-total', (k.total_companies != null ? k.total_companies : 0));
setVal('kpi-avg', (k.avg_compliance != null ? k.avg_compliance + '%' : '—'));
setVal('kpi-overdue', (k.overdue_total != null ? k.overdue_total : 0));
setVal('kpi-upcoming', (k.upcoming_total != null ? k.upcoming_total : 0));
// Scadenze in ritardo: rossa se > 0.
const over = document.getElementById('kpi-overdue');
if (over) {
const hot = (k.overdue_total || 0) > 0;
over.classList.toggle('amber', !hot);
over.style.borderLeftColor = hot ? 'var(--c-red)' : '';
const v = over.querySelector('.val'); if (v) v.style.color = hot ? 'var(--c-red)' : '';
const ib = over.querySelector('.ibox');
if (ib) { ib.style.background = hot ? '#FBE9E9' : ''; ib.style.color = hot ? '#B23636' : ''; }
}
} }
// ── Render companies ───────────────────────────────────────────── // ── Render companies ─────────────────────────────────────────────
function renderCompanies(orgs) { function renderCompanies(orgs) {
const grid = document.getElementById('companies-grid'); const grid = document.getElementById('companies-grid');
grid.innerHTML = orgs.map(org => renderCompanyCard(org)).join(''); grid.innerHTML = orgs.map(org => renderCompanyCard(org)).join('');
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
} }
function renderCompanyCard(org) { function renderCompanyCard(org) {
const orgId = org.id || org.organization_id; const orgId = org.id;
const name = org.name || 'Azienda'; const name = org.name || 'Azienda';
const vat = org.vat_number ? 'IT' + org.vat_number : '-';
const sector = org.sector || ''; const sector = org.sector || '';
const entityType = org.entity_type || 'not_applicable'; const entityType = org.entity_type || 'not_applicable';
const voluntary = org.voluntary_compliance ? true : false; const voluntary = org.voluntary_compliance ? true : false;
const score = (org.compliance_score === null || org.compliance_score === undefined) ? null : Number(org.compliance_score);
const risks = org.open_risks || 0;
const incidents = org.open_incidents || 0;
const overdue = org.deadlines_overdue || 0;
const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); const initials = name.split(' ').map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() || '?';
const entityBadge = renderEntityBadge(entityType, voluntary); const entityBadge = renderEntityBadge(entityType, voluntary);
const sectorLabel = getSectorLabel(sector); const sectorLabel = getSectorLabel(sector);
const nameEsc = escapeHtml(name).replace(/'/g, "\\'");
const scoreColor = complianceColor(score);
const scoreText = (score === null) ? '—' : (score + '%');
const scoreWidth = (score === null) ? 0 : Math.max(0, Math.min(100, score));
const overdueBadge = overdue > 0
? `<span class="overdue-badge" title="Scadenze in ritardo"><i data-lucide="alarm-clock-off" class="ic" style="width:13px;height:13px"></i>${overdue} in ritardo</span>`
: '';
return ` return `
<div class="company-card" data-name="${escapeHtml(name.toLowerCase())}" data-org-id="${orgId}"> <div class="company-card" data-name="${escapeHtml(name.toLowerCase())}" data-org-id="${orgId}" onclick="manageCompany(${orgId})" role="button" tabindex="0"
onkeydown="if(event.key==='Enter'){manageCompany(${orgId});}">
<div class="company-card-header"> <div class="company-card-header">
<div class="company-card-avatar">${escapeHtml(initials)}</div> <div class="company-card-avatar">${escapeHtml(initials)}</div>
<div class="company-card-info"> <div class="company-card-info">
<div class="company-card-name" title="${escapeHtml(name)}">${escapeHtml(name)}</div> <div class="company-card-name" title="${escapeHtml(name)}">${escapeHtml(name)}</div>
<div class="company-card-vat">${escapeHtml(vat)}</div> <div class="company-card-vat">${escapeHtml(sectorLabel)}</div>
</div> </div>
${entityBadge} ${entityBadge}
</div> </div>
<!-- Score bar (loaded async) --> <!-- Score bar -->
<div class="company-score-row" id="score-row-${orgId}" style="display:none"> <div class="company-score-row">
<span class="company-score-label">Compliance</span> <span class="company-score-label">Compliance</span>
<div class="company-score-bar"> <div class="company-score-bar">
<div class="company-score-bar-fill" id="score-fill-${orgId}" style="width:0%;background:#ccc"></div> <div class="company-score-bar-fill" style="width:${scoreWidth}%;background:${scoreColor}"></div>
</div> </div>
<span class="company-score-value" id="score-val-${orgId}">-</span> <span class="company-score-value" style="color:${scoreColor}">${scoreText}</span>
</div> </div>
<!-- Stats row (loaded async) --> <!-- Stats row -->
<div class="company-stats" id="stats-row-${orgId}" style="display:none"> <div class="company-stats">
<div class="company-stat"> <div class="company-stat">
<span class="company-stat-value" id="stat-risks-${orgId}">-</span> <span class="company-stat-value">${risks}</span>
<span class="company-stat-label">Rischi aperti</span> <span class="company-stat-label">Rischi aperti</span>
</div> </div>
<div class="company-stat"> <div class="company-stat">
<span class="company-stat-value" id="stat-incidents-${orgId}">-</span> <span class="company-stat-value">${incidents}</span>
<span class="company-stat-label">Incidenti attivi</span> <span class="company-stat-label">Incidenti aperti</span>
</div> </div>
${overdueBadge ? `<div style="margin-left:auto">${overdueBadge}</div>` : ''}
</div> </div>
<div class="company-card-footer"> <div class="company-card-footer">
<span class="company-card-sector">${escapeHtml(sectorLabel)}</span> <span class="company-card-sector">${escapeHtml(sectorLabel)}</span>
<button class="btn btn-ghost btn-sm" onclick="openConnectors(${orgId}, '${escapeHtml(name).replace(/'/g, "\\'")}')" title="Connettori Evidence Automation"> <button class="btn-ghost btn-sm" onclick="event.stopPropagation(); openConnectors(${orgId}, '${nameEsc}')" title="Connettori Evidence Automation">
Connettori Connettori
</button> </button>
<button class="btn btn-primary btn-sm" onclick="manageCompany(${orgId})"> <button class="btn-primary btn-sm" onclick="event.stopPropagation(); manageCompany(${orgId})">
Gestisci Entra
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg> <i data-lucide="arrow-right" class="ic"></i>
</button> </button>
</div> </div>
</div>`; </div>`;
} }
// ── Render scadenze generali (tutte le aziende) ──────────────────
function renderDeadlines(deadlines) {
const wrap = document.getElementById('deadlines-wrap');
const hint = document.getElementById('deadlines-hint');
if (!deadlines || deadlines.length === 0) {
wrap.innerHTML = `<div class="deadlines-empty" data-i18n="companies.deadlines_empty">Nessuna scadenza nei prossimi 30 giorni</div>`;
if (hint) hint.textContent = '';
if (typeof I18n !== 'undefined') I18n.applyTranslations();
return;
}
const rows = deadlines.slice(0, 50).map(dl => {
const orgName = escapeHtml(dl.org_name || '—');
const title = escapeHtml(dl.title || '—');
const typeLabel = escapeHtml(DEADLINE_TYPE_LABELS[dl.type] || dl.type || '—');
const dateTxt = formatDate(dl.due_date);
const badge = deadlineBadge(dl);
return `<div class="deadline-row">
<span class="deadline-org" title="${orgName}">${orgName}</span>
<span class="deadline-title" title="${title}">${title}</span>
<span class="deadline-type">${typeLabel}</span>
<span class="deadline-date">${dateTxt}</span>
<span class="deadline-status">${badge}</span>
</div>`;
}).join('');
wrap.innerHTML = rows;
if (hint) hint.textContent = deadlines.length + (deadlines.length === 1 ? ' scadenza' : ' scadenze');
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
}
function deadlineBadge(dl) {
if (dl.overdue) {
return `<span class="dl-badge overdue">In ritardo</span>`;
}
const days = daysUntil(dl.due_date);
if (days === null) return '';
if (days <= 0) return `<span class="dl-badge soon">Oggi</span>`;
const cls = days <= 7 ? 'soon' : 'ok';
return `<span class="dl-badge ${cls}">Tra ${days} ${days === 1 ? 'giorno' : 'giorni'}</span>`;
}
// "YYYY-MM-DD HH:MM:SS" → Date (local). Ritorna null se non parsabile.
function parseDue(str) {
if (!str) return null;
const d = new Date(String(str).replace(' ', 'T'));
return isNaN(d.getTime()) ? null : d;
}
function formatDate(str) {
const d = parseDue(str);
if (!d) return '—';
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
return `${dd}/${mm}/${d.getFullYear()}`;
}
function daysUntil(str) {
const d = parseDue(str);
if (!d) return null;
const today = new Date(); today.setHours(0, 0, 0, 0);
const target = new Date(d.getFullYear(), d.getMonth(), d.getDate());
return Math.round((target - today) / 86400000);
}
// ── Connettori per-azienda (Evidence Automation) ───────────── // ── Connettori per-azienda (Evidence Automation) ─────────────
const CONNECTOR_LABELS = { const CONNECTOR_LABELS = {
m365: 'Microsoft 365 / Entra ID', google: 'Google Workspace', aws: 'Amazon Web Services', m365: 'Microsoft 365 / Entra ID', google: 'Google Workspace', aws: 'Amazon Web Services',
@@ -480,45 +448,45 @@
headers: { 'Authorization': `Bearer ${api.token}`, 'X-Organization-Id': orgId } headers: { 'Authorization': `Bearer ${api.token}`, 'X-Organization-Id': orgId }
}); });
const data = await res.json(); const data = await res.json();
if (!data.success) { showConnModal(orgName, `<p style="color:#ef4444;">${escapeHtml(data.message || 'Errore')}</p>`); return; } if (!data.success) { showConnModal(orgName, `<p style="color:var(--c-red);">${escapeHtml(data.message || 'Errore')}</p>`); return; }
const existing = {}; const existing = {};
(data.data.connectors || []).forEach(c => existing[c.connector_type] = c); (data.data.connectors || []).forEach(c => existing[c.connector_type] = c);
const rows = (data.data.available_types || []).map(t => { const rows = (data.data.available_types || []).map(t => {
const c = existing[t] || {}; const c = existing[t] || {};
const cfg = (c.config && typeof c.config === 'object') ? c.config : {}; const cfg = (c.config && typeof c.config === 'object') ? c.config : {};
const ss = c.secret_status || 'not_set'; const ss = c.secret_status || 'not_set';
const ssBadge = { not_set: ['Segreto non impostato', '#9ca3af'], pending: ['Segreto in attesa', '#eab308'], configured: ['Segreto configurato', '#22c55e'] }[ss]; const ssBadge = { not_set: ['Segreto non impostato', '#9ca3af'], pending: ['Segreto in attesa', '#D98324'], configured: ['Segreto configurato', '#1E8A5A'] }[ss];
return `<div class="conn-row" data-type="${t}" style="border:1px solid var(--gray-200);border-radius:8px;padding:12px;margin-bottom:10px;"> return `<div class="conn-row" data-type="${t}">
<div style="display:flex;justify-content:space-between;align-items:center;"> <div style="display:flex;justify-content:space-between;align-items:center;">
<strong>${escapeHtml(CONNECTOR_LABELS[t] || t)}</strong> <strong>${escapeHtml(CONNECTOR_LABELS[t] || t)}</strong>
<label style="font-size:.8rem;"><input type="checkbox" class="c-en" ${c.enabled ? 'checked' : ''}> Attivo</label> <label style="font-size:.8rem;"><input type="checkbox" class="c-en" ${c.enabled ? 'checked' : ''}> Attivo</label>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:8px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:8px;">
<input class="c-tenant" placeholder="tenant_id / account_id" value="${escapeHtml(cfg.tenant_id || cfg.account_id || '')}" style="padding:6px;border:1px solid var(--gray-300);border-radius:6px;"> <input class="c-tenant" placeholder="tenant_id / account_id" value="${escapeHtml(cfg.tenant_id || cfg.account_id || '')}">
<input class="c-client" placeholder="client_id / app_id" value="${escapeHtml(cfg.client_id || cfg.app_id || '')}" style="padding:6px;border:1px solid var(--gray-300);border-radius:6px;"> <input class="c-client" placeholder="client_id / app_id" value="${escapeHtml(cfg.client_id || cfg.app_id || '')}">
</div> </div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<span style="font-size:.72rem;color:${ssBadge[1]};">● ${ssBadge[0]}</span> <span style="font-size:.72rem;color:${ssBadge[1]};">● ${ssBadge[0]}</span>
<button class="btn btn-sm btn-primary" onclick="saveConn('${t}', this)">Salva</button> <button class="btn-primary btn-sm" onclick="saveConn('${t}', this)">Salva</button>
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
showConnModal(orgName, ` showConnModal(orgName, `
<p style="font-size:.82rem;color:var(--gray-600);margin-bottom:12px;"> <p style="font-size:.82rem;color:var(--muted);margin-bottom:12px;">
Configura i connettori di <strong>Evidence Automation</strong>. Qui si salvano solo i parametri Configura i connettori di <strong>Evidence Automation</strong>. Qui si salvano solo i parametri
NON segreti (tenant/client id). Il <strong>client secret</strong> va caricato nel vault dall'amministratore NON segreti (tenant/client id). Il <strong>client secret</strong> va caricato nel vault dall'amministratore
(il comando viene mostrato dopo il salvataggio).</p>${rows}`); (il comando viene mostrato dopo il salvataggio).</p>${rows}`);
} catch (e) { showConnModal(orgName, '<p style="color:#ef4444;">Errore di connessione</p>'); } } catch (e) { showConnModal(orgName, '<p style="color:var(--c-red);">Errore di connessione</p>'); }
} }
function showConnModal(orgName, body) { function showConnModal(orgName, body) {
const old = document.getElementById('conn-modal'); if (old) old.remove(); const old = document.getElementById('conn-modal'); if (old) old.remove();
document.body.insertAdjacentHTML('beforeend', ` document.body.insertAdjacentHTML('beforeend', `
<div id="conn-modal" onclick="if(event.target===this)closeConn()" style="position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;padding:20px;"> <div id="conn-modal" onclick="if(event.target===this)closeConn()" style="position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;padding:20px;">
<div style="background:var(--card-bg,#fff);border-radius:12px;max-width:640px;width:100%;max-height:85vh;overflow-y:auto;padding:22px;"> <div style="background:var(--card);border-radius:12px;max-width:640px;width:100%;max-height:85vh;overflow-y:auto;padding:22px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;">
<h3 style="margin:0;">Connettori — ${escapeHtml(orgName)}</h3> <h3 style="margin:0;">Connettori — ${escapeHtml(orgName)}</h3>
<button class="btn btn-ghost btn-sm" onclick="closeConn()">✕</button> <button class="btn-ghost btn-sm" onclick="closeConn()">✕</button>
</div>${body} </div>${body}
</div></div>`); </div></div>`);
} }
@@ -564,45 +532,6 @@
return `<span class="entity-badge not_applicable">Non NIS2</span>`; return `<span class="entity-badge not_applicable">Non NIS2</span>`;
} }
// ── Load compliance score per org ────────────────────────────────
async function loadOrgScore(orgId) {
try {
// Temporarily switch to this org for the API call
const savedOrgId = api.orgId;
api.orgId = orgId;
const result = await api.getDashboardOverview();
api.orgId = savedOrgId;
if (result.success && result.data) {
const score = result.data.compliance_score || 0;
const risks = result.data.open_risks || 0;
const incidents = result.data.active_incidents || 0;
const scoreRow = document.getElementById('score-row-' + orgId);
const scoreVal = document.getElementById('score-val-' + orgId);
const scoreFill = document.getElementById('score-fill-' + orgId);
const statsRow = document.getElementById('stats-row-' + orgId);
const statRisks = document.getElementById('stat-risks-' + orgId);
const statIncidents = document.getElementById('stat-incidents-' + orgId);
if (scoreRow) scoreRow.style.display = 'flex';
if (scoreVal) {
scoreVal.textContent = score + '%';
scoreVal.style.color = getScoreColor(score);
}
if (scoreFill) {
scoreFill.style.width = score + '%';
scoreFill.style.background = getScoreColor(score);
}
if (statsRow) statsRow.style.display = 'flex';
if (statRisks) statRisks.textContent = risks;
if (statIncidents) statIncidents.textContent = incidents;
}
} catch (e) {
// Silently ignore per-org errors
}
}
// ── Manage company (switch org + go to dashboard) ───────────────── // ── Manage company (switch org + go to dashboard) ─────────────────
function manageCompany(orgId) { function manageCompany(orgId) {
api.setOrganization(orgId); api.setOrganization(orgId);
@@ -653,7 +582,7 @@
} }
// ── Init ───────────────────────────────────────────────────────── // ── Init ─────────────────────────────────────────────────────────
loadCompanies(); loadPortfolio();
</script> </script>
</body> </body>
</html> </html>
+99 -68
View File
@@ -3,61 +3,62 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Competenze - NIS2 Agile</title> <title>Competenze · NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <!-- UI V3: Inter self-host + design system v3.css. No external CDN, no Bootstrap Italia. -->
<link rel="stylesheet" href="/css/style.css?v=20260618"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style> <style>
.cmp-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:16px; font-size:.92rem; line-height:1.6; } /* V3+ page-specific (componenti non coperti da v3.css): tab interni, tabella, gap card, modali. Token V3. */
.cmp-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 18px; } .cmp-intro { background:#EFF4FB; border-left:4px solid var(--c-blue); padding:14px 18px; border-radius:10px; margin-bottom:14px; font-size:13.5px; line-height:1.6; color:var(--ink); }
.cmp-note { font-size:12.5px; color:#9A6418; background:#FBF1E2; border:1px solid #f3d9a8; border-radius:10px; padding:10px 12px; margin:0 0 18px; }
.cmp-tabs { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:18px; } .cmp-tabs { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:18px; }
.cmp-tab { flex:1 1 160px; min-width:140px; padding:10px 12px; border:1.5px solid var(--gray-200,#e5e7eb); border-radius:10px; background:#fff; cursor:pointer; font-size:.85rem; font-weight:600; text-align:center; } .cmp-tab { flex:1 1 160px; min-width:140px; padding:11px 12px; border:1.5px solid var(--line); border-radius:10px; background:#fff; cursor:pointer; font:inherit; font-size:13px; font-weight:600; text-align:center; color:var(--ink); }
.cmp-tab.active { border-color:var(--primary,#2563eb); background:#eff6ff; color:var(--primary,#2563eb); } .cmp-tab.active { border-color:var(--c-blue); background:#EFF4FB; color:var(--c-blue); }
.cmp-panel { display:none; } .cmp-panel { display:none; }
.cmp-panel.active { display:block; } .cmp-panel.active { display:block; }
.cmp-table { width:100%; border-collapse:collapse; font-size:.88rem; } .cmp-table { width:100%; border-collapse:collapse; font-size:13.5px; }
.cmp-table th, .cmp-table td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--gray-100,#f3f4f6); vertical-align:top; } .cmp-table th, .cmp-table td { text-align:left; padding:10px 11px; border-bottom:1px solid var(--line); vertical-align:top; }
.cmp-table th { font-size:.74rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); } .cmp-table th { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); font-weight:700; }
.cmp-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; } .cmp-badge { display:inline-block; font-size:10.5px; font-weight:700; padding:2px 8px; border-radius:6px; }
.cmp-badge.global { background:#ede9fe; color:#5b21b6; } .cmp-badge.global { background:#EFE7FB; color:#5b21b6; }
.cmp-badge.area { background:#f1f5f9; color:#475569; } .cmp-badge.area { background:#EEF2F7; color:#5b6b80; }
.cmp-badge.lvl { background:#e0f2fe; color:#075985; } .cmp-badge.lvl { background:#E7F0FB; color:#1F5FA8; }
.cmp-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; } .cmp-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.cmp-toolbar select, .cmp-inline select, .cmp-inline input { padding:8px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.88rem; } .cmp-toolbar select, .cmp-inline select, .cmp-inline input { padding:8px 10px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:13.5px; background:#fff; color:var(--ink); }
.cmp-inline { display:flex; gap:8px; flex-wrap:wrap; align-items:flex-end; background:#f9fafb; border:1px solid var(--gray-100,#f3f4f6); border-radius:10px; padding:12px; margin-bottom:14px; } .cmp-inline { display:flex; gap:8px; flex-wrap:wrap; align-items:flex-end; background:#FAFCFE; border:1px solid var(--line); border-radius:10px; padding:12px; margin-bottom:14px; }
.cmp-inline .fld { display:flex; flex-direction:column; gap:3px; } .cmp-inline .fld { display:flex; flex-direction:column; gap:3px; }
.cmp-inline .fld label { font-size:.72rem; font-weight:600; color:var(--gray-500,#6b7280); } .cmp-inline .fld label { font-size:11px; font-weight:700; color:var(--muted); }
.cmp-actions-cell { display:flex; gap:6px; flex-wrap:wrap; } .cmp-actions-cell { display:flex; gap:6px; flex-wrap:wrap; }
.cmp-actions-cell .btn, .cmp-toolbar .btn { padding:4px 9px; font-size:.76rem; }
/* Gap cards */ /* Gap cards */
.gap-role { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; margin-bottom:14px; overflow:hidden; } .gap-role { border:1px solid var(--line); border-radius:11px; margin-bottom:14px; overflow:hidden; }
.gap-role-head { background:var(--gray-50,#f9fafb); padding:11px 14px; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:6px; } .gap-role-head { background:#FAFCFE; padding:12px 14px; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:6px; }
.gap-role-title { font-weight:700; } .gap-role-title { font-weight:700; }
.gap-role-sub { font-size:.82rem; color:var(--gray-500,#6b7280); } .gap-role-sub { font-size:12px; color:var(--muted); }
.gap-row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; padding:10px 14px; border-top:1px solid var(--gray-100,#f3f4f6); } .gap-row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; padding:11px 14px; border-top:1px solid var(--line); }
.gap-row .skill { flex:1 1 220px; font-weight:600; font-size:.9rem; } .gap-row .skill { flex:1 1 220px; font-weight:600; font-size:13.5px; }
.gap-meter { font-size:.82rem; } .gap-meter { font-size:12.5px; color:var(--muted); }
.gap-pill { display:inline-block; font-size:.72rem; font-weight:800; padding:2px 9px; border-radius:20px; } .gap-pill { display:inline-block; font-size:10.5px; font-weight:800; padding:2px 9px; border-radius:20px; }
.gap-pill.ok { background:#dcfce7; color:#166534; } .gap-pill.ok { background:#E6F4EC; color:#1E7A4E; }
.gap-pill.bad { background:#fee2e2; color:#991b1b; } .gap-pill.bad { background:#FBE9E9; color:#B23636; }
.gap-row .ctas { display:flex; gap:6px; flex-wrap:wrap; } .gap-row .ctas { display:flex; gap:6px; flex-wrap:wrap; }
.gap-row .ctas .btn { padding:4px 9px; font-size:.76rem; } .cmp-empty { text-align:center; padding:34px 16px; color:var(--muted); }
.cmp-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); }
/* Modale */ /* Modale */
.cm-overlay { display:none; position:fixed; inset:0; background:rgba(15,23,42,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 16px; } .cm-overlay { display:none; position:fixed; inset:0; background:rgba(15,38,60,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 16px; }
.cm-overlay.open { display:flex; } .cm-overlay.open { display:flex; }
.cm-dialog { background:#fff; border-radius:12px; width:100%; max-width:560px; box-shadow:0 20px 50px rgba(0,0,0,.25); } .cm-dialog { background:#fff; border-radius:14px; width:100%; max-width:560px; box-shadow:0 20px 50px rgba(11,40,80,.25); }
.cm-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); } .cm-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--line); }
.cm-head h3 { margin:0; font-size:1.05rem; } .cm-head h3 { margin:0; font-size:17px; font-weight:800; }
.cm-body { padding:18px 20px; } .cm-body { padding:18px 20px; }
.cm-foot { padding:14px 20px; border-top:1px solid var(--gray-100,#f3f4f6); display:flex; justify-content:flex-end; gap:10px; } .cm-foot { padding:14px 20px; border-top:1px solid var(--line); display:flex; justify-content:flex-end; gap:10px; }
.cm-field { margin-bottom:14px; } .cm-field { margin-bottom:14px; }
.cm-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; } .cm-field label { display:block; font-weight:600; font-size:12.5px; margin-bottom:5px; }
.cm-field input[type=text], .cm-field select, .cm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; } .cm-field input[type=text], .cm-field select, .cm-field textarea { width:100%; padding:9px 11px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:13.5px; background:#fff; color:var(--ink); }
.cm-field textarea { min-height:64px; resize:vertical; } .cm-field textarea { min-height:64px; resize:vertical; }
.cm-err { color:#b91c1c; font-size:.82rem; min-height:18px; } .cm-err { color:var(--c-red); font-size:12.5px; min-height:18px; }
.cm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); } .cm-close { background:none; border:none; font-size:22px; line-height:1; cursor:pointer; color:#9aa8ba; }
.cm-maplist { margin-top:10px; } .cm-maplist { margin-top:10px; }
.cm-maprow { display:flex; justify-content:space-between; align-items:center; padding:7px 0; border-top:1px solid var(--gray-100,#f3f4f6); font-size:.86rem; } .cm-maprow { display:flex; justify-content:space-between; align-items:center; padding:8px 0; border-top:1px solid var(--line); font-size:13px; }
.savehint { font-size:12px; color:var(--muted); }
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -74,17 +75,24 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <div id="topnav"></div>
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="comp.title">Competenze</h2>
<div class="content-header-actions">
<span class="savehint" id="cmp-hint" role="status" aria-live="polite"></span>
</div>
</header>
<div class="content-body"> <main class="wrap">
<nav class="crumb" aria-label="breadcrumb">
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<i data-lucide="chevron-right" class="ic"></i><span>Struttura interna</span>
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="comp.title">Competenze</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="comp.title">Competenze</h1>
<p class="sub">Skill matrix, requisiti per ruolo e copertura dei gap</p>
</div>
<span class="savehint" id="cmp-hint" role="status" aria-live="polite"></span>
</div>
<div class="stack">
<section class="card card-pad">
<div class="cmp-intro"> <div class="cmp-intro">
<strong>Competenze, requisiti e gap.</strong> <strong>Competenze, requisiti e gap.</strong>
Definisci le competenze necessarie, assegnale ai ruoli dell'organigramma, registra quelle possedute dalle persone e individua i divari (gap) da colmare con formazione o azioni correttive. Definisci le competenze necessarie, assegnale ai ruoli dell'organigramma, registra quelle possedute dalle persone e individua i divari (gap) da colmare con formazione o azioni correttive.
@@ -103,7 +111,7 @@
<!-- TAB 1 — Catalogo competenze --> <!-- TAB 1 — Catalogo competenze -->
<div class="cmp-panel active" id="panel-catalog"> <div class="cmp-panel active" id="panel-catalog">
<div class="cmp-toolbar"> <div class="cmp-toolbar">
<button class="btn btn-primary btn-sm" onclick="cmpOpenSkill()">+ Nuova competenza</button> <button class="btn-primary" onclick="cmpOpenSkill()"><i data-lucide="plus" class="ic"></i>Nuova competenza</button>
<span class="gap-role-sub" id="cmp-catalog-count"></span> <span class="gap-role-sub" id="cmp-catalog-count"></span>
</div> </div>
<div id="cmp-catalog"></div> <div id="cmp-catalog"></div>
@@ -112,13 +120,13 @@
<!-- TAB 2 — Requisiti per ruolo --> <!-- TAB 2 — Requisiti per ruolo -->
<div class="cmp-panel" id="panel-roles"> <div class="cmp-panel" id="panel-roles">
<div class="cmp-toolbar"> <div class="cmp-toolbar">
<label for="cmp-role-sel" style="font-weight:600;font-size:.86rem;">Ruolo:</label> <label for="cmp-role-sel" style="font-weight:600;font-size:13px;">Ruolo:</label>
<select id="cmp-role-sel" onchange="cmpLoadRoleSkills()"></select> <select id="cmp-role-sel" onchange="cmpLoadRoleSkills()"></select>
</div> </div>
<div class="cmp-inline" id="cmp-role-add" style="display:none;"> <div class="cmp-inline" id="cmp-role-add" style="display:none;">
<div class="fld"><label>Competenza</label><select id="cmp-rs-skill"></select></div> <div class="fld"><label>Competenza</label><select id="cmp-rs-skill"></select></div>
<div class="fld"><label>Livello richiesto</label><select id="cmp-rs-level"></select></div> <div class="fld"><label>Livello richiesto</label><select id="cmp-rs-level"></select></div>
<button class="btn btn-primary btn-sm" onclick="cmpAddRoleSkill()">Aggiungi requisito</button> <button class="btn-primary" onclick="cmpAddRoleSkill()">Aggiungi requisito</button>
</div> </div>
<div id="cmp-role-skills"></div> <div id="cmp-role-skills"></div>
</div> </div>
@@ -126,7 +134,7 @@
<!-- TAB 3 — Competenze persone --> <!-- TAB 3 — Competenze persone -->
<div class="cmp-panel" id="panel-users"> <div class="cmp-panel" id="panel-users">
<div class="cmp-toolbar"> <div class="cmp-toolbar">
<label for="cmp-user-sel" style="font-weight:600;font-size:.86rem;">Persona:</label> <label for="cmp-user-sel" style="font-weight:600;font-size:13px;">Persona:</label>
<select id="cmp-user-sel" onchange="cmpLoadUserSkills()"></select> <select id="cmp-user-sel" onchange="cmpLoadUserSkills()"></select>
</div> </div>
<div class="cmp-inline" id="cmp-user-add" style="display:none;"> <div class="cmp-inline" id="cmp-user-add" style="display:none;">
@@ -134,7 +142,7 @@
<div class="fld"><label>Livello posseduto</label><select id="cmp-us-level"></select></div> <div class="fld"><label>Livello posseduto</label><select id="cmp-us-level"></select></div>
<div class="fld"><label>Corso (opz.)</label><select id="cmp-us-course"></select></div> <div class="fld"><label>Corso (opz.)</label><select id="cmp-us-course"></select></div>
<div class="fld"><label>Evidenza (opz.)</label><input type="text" id="cmp-us-evidence" maxlength="500" placeholder="es. attestato, data"></div> <div class="fld"><label>Evidenza (opz.)</label><input type="text" id="cmp-us-evidence" maxlength="500" placeholder="es. attestato, data"></div>
<button class="btn btn-primary btn-sm" onclick="cmpAddUserSkill()">Registra</button> <button class="btn-primary" onclick="cmpAddUserSkill()">Registra</button>
</div> </div>
<div id="cmp-user-skills"></div> <div id="cmp-user-skills"></div>
</div> </div>
@@ -142,14 +150,14 @@
<!-- TAB 4 — Gap & azioni --> <!-- TAB 4 — Gap & azioni -->
<div class="cmp-panel" id="panel-gap"> <div class="cmp-panel" id="panel-gap">
<div class="cmp-toolbar"> <div class="cmp-toolbar">
<button class="btn btn-outline btn-sm" onclick="cmpLoadGap()">Ricalcola gap</button> <button class="btn-ghost" onclick="cmpLoadGap()"><i data-lucide="refresh-cw" class="ic"></i>Ricalcola gap</button>
<span class="gap-role-sub" id="cmp-gap-summary"></span> <span class="gap-role-sub" id="cmp-gap-summary"></span>
</div> </div>
<div id="cmp-gap"></div> <div id="cmp-gap"></div>
</div> </div>
</div> </section>
</main> </div>
</div> </main>
<!-- Modale competenza (crea/modifica) --> <!-- Modale competenza (crea/modifica) -->
<div class="cm-overlay" id="cmp-skill-modal" role="dialog" aria-modal="true" aria-labelledby="cmp-skill-title"> <div class="cm-overlay" id="cmp-skill-modal" role="dialog" aria-modal="true" aria-labelledby="cmp-skill-title">
@@ -162,7 +170,7 @@
<div class="cm-field"><label for="cmp-skill-desc">Descrizione</label><textarea id="cmp-skill-desc" maxlength="2000"></textarea></div> <div class="cm-field"><label for="cmp-skill-desc">Descrizione</label><textarea id="cmp-skill-desc" maxlength="2000"></textarea></div>
<div class="cm-err" id="cmp-skill-err" role="alert"></div> <div class="cm-err" id="cmp-skill-err" role="alert"></div>
</div> </div>
<div class="cm-foot"><button class="btn btn-outline" type="button" onclick="cmpClose('cmp-skill-modal')">Annulla</button><button class="btn btn-primary" type="button" id="cmp-skill-save" onclick="cmpSaveSkill()">Salva</button></div> <div class="cm-foot"><button class="btn-ghost" type="button" onclick="cmpClose('cmp-skill-modal')">Annulla</button><button class="btn-primary" type="button" id="cmp-skill-save" onclick="cmpSaveSkill()">Salva</button></div>
</div> </div>
</div> </div>
@@ -174,21 +182,44 @@
<input type="hidden" id="cmp-courses-skill-id"> <input type="hidden" id="cmp-courses-skill-id">
<div class="cm-field" style="display:flex;gap:8px;align-items:flex-end;"> <div class="cm-field" style="display:flex;gap:8px;align-items:flex-end;">
<div style="flex:1;"><label for="cmp-courses-sel">Aggiungi corso</label><select id="cmp-courses-sel"></select></div> <div style="flex:1;"><label for="cmp-courses-sel">Aggiungi corso</label><select id="cmp-courses-sel"></select></div>
<button class="btn btn-primary" type="button" onclick="cmpMapCourse()">Aggiungi</button> <button class="btn-primary" type="button" onclick="cmpMapCourse()">Aggiungi</button>
</div> </div>
<div class="cm-maplist" id="cmp-courses-list"></div> <div class="cm-maplist" id="cmp-courses-list"></div>
<div class="cm-err" id="cmp-courses-err" role="alert"></div> <div class="cm-err" id="cmp-courses-err" role="alert"></div>
</div> </div>
<div class="cm-foot"><button class="btn btn-outline" type="button" onclick="cmpClose('cmp-courses-modal')">Chiudi</button></div> <div class="cm-foot"><button class="btn-ghost" type="button" onclick="cmpClose('cmp-courses-modal')">Chiudi</button></div>
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3. competenze.js si auto-inizializza su DOMContentLoaded (checkAuth/loadSidebar/I18n/Help). -->
<script src="/js/common.js?v=20260631"></script> <script src="/js/api.js?v=20260730a"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/common.js?v=20260729b"></script>
<script src="/js/common-bi.js?v=20260631"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/i18n.js?v=20260631"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/help.js?v=20260618p2"></script> <script src="/js/i18n.js?v=20260731"></script>
<script src="/js/competenze.js?v=20260617"></script> <script src="/js/help.js?v=20260806b"></script>
<script src="/js/competenze.js?v=20260627"></script>
<script>
function v3Icons() { if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} } }
v3Icons();
document.addEventListener('DOMContentLoaded', function () {
v3Icons();
var root = document.querySelector('main.wrap');
if (root && window.MutationObserver) {
// Anti-loop: createIcons() muta il DOM (<i>->svg) → osservare "ogni mutazione"
// e ri-chiamarlo puo' auto-ritriggerarsi (freeze). Debounce + disconnect.
var applying = false, timer = null;
var mo = new MutationObserver(function () {
if (applying) return;
clearTimeout(timer);
timer = setTimeout(function () {
applying = true; mo.disconnect(); v3Icons();
mo.observe(root, { childList: true, subtree: true }); applying = false;
}, 120);
});
mo.observe(root, { childList: true, subtree: true });
}
});
</script>
</body> </body>
</html> </html>
+120
View File
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Connettori Discovery · NIS2 Agile</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<!-- UI V3: Inter self-host + design system v3.css (self-host only, nessun CDN esterno). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<!-- style.css resta caricato DOPO v3.css: veste i widget renderizzati via JS
(tabelle/form/modali). Self-hostato (nessun CDN). v3.css governa chrome + shell. -->
<meta name="theme-color" content="#0066CC">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
<script src="/js/pwa.js?v=20260614" defer></script>
</head>
<body>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="wrap">
<nav class="crumb" aria-label="breadcrumb">
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<i data-lucide="chevron-right" class="ic"></i><span>Connettori Discovery</span>
</nav>
<div class="page-head">
<div>
<h1>Connettori Discovery</h1>
<p class="sub">Scansione rete/cloud → auto-popola l'Inventario (asset, dipendenze, flussi ID.AM-03)</p>
</div>
<div class="content-header-actions">
<button class="btn-primary" id="btn-dc-new"><i data-lucide="plus" class="ic"></i>Nuovo connettore</button>
</div>
</div>
<div class="content-body">
<div class="card" style="margin-bottom:16px">
<div class="card-body">
<p style="margin:0;color:var(--gray-600)">
I connettori mappano automaticamente la tua <strong>rete/cloud</strong> e <strong>popolano l'Inventario</strong>
(asset + rilevanza NIS2 + Mappa Dipendenze + flussi di rete ID.AM-03). Puoi creare
<strong>più connettori per azienda</strong> (es. uno per sede). Ogni connettore ha una <strong>chiave</strong>
che usi nell'<em>agente di discovery</em> (vedi <code>scripts/discovery-agent.mjs</code>).
</p>
</div>
</div>
<div class="card" id="dc-form-card" style="display:none;margin-bottom:16px">
<div class="card-header"><h3 style="margin:0">Nuovo connettore</h3></div>
<div class="card-body">
<div class="form-row" style="display:flex;gap:12px;flex-wrap:wrap">
<div class="form-group" style="flex:1;min-width:200px">
<label class="form-label" for="dc-name">Nome</label>
<input id="dc-name" class="form-input" placeholder="Es. Rete sede Modena" maxlength="120">
</div>
<div class="form-group" style="min-width:160px">
<label class="form-label" for="dc-type">Tipo</label>
<select id="dc-type" class="form-select">
<option value="network">Rete (scan IP)</option>
<option value="aws">AWS</option>
<option value="azure">Azure</option>
<option value="gcp">GCP</option>
<option value="cmdb">CMDB</option>
<option value="agent">Agente generico</option>
<option value="custom">Custom</option>
</select>
</div>
<div class="form-group" style="min-width:160px">
<label class="form-label" for="dc-schedule">Schedulazione</label>
<select id="dc-schedule" class="form-select">
<option value="manual">Manuale</option>
<option value="daily">Giornaliera</option>
<option value="hourly">Oraria</option>
<option value="weekly">Settimanale</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label" for="dc-targets">Target (CIDR/IP, separati da virgola) — descrittivo / usato dall'agente</label>
<input id="dc-targets" class="form-input" placeholder="192.168.1.0/24, 10.0.0.5">
</div>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn-primary" id="btn-dc-create"><i data-lucide="check" class="ic"></i>Crea connettore</button>
<button class="btn-ghost" id="btn-dc-cancel">Annulla</button>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div id="dc-list">Caricamento…</div>
</div>
</div>
</div>
</main>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → pagina -->
<script src="/js/api.js?v=20260730a"></script>
<script src="/js/common.js?v=20260729b"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script src="/js/connettori-discovery.js?v=20260627"></script>
<script>
if (typeof checkAuth === 'function' && !checkAuth()) {
// checkAuth reindirizza a login
} else {
if (typeof loadSidebar === 'function') loadSidebar(); // = loadTopnavV3()
if (typeof I18n !== 'undefined' && I18n.init) I18n.init();
if (typeof HelpSystem !== 'undefined' && HelpSystem.init) HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
if (window.DiscoveryUI) DiscoveryUI.init();
}
</script>
</body>
</html>
+43 -23
View File
@@ -3,10 +3,21 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Controlli periodici - NIS2 Agile</title> <title>Controlli periodici · NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia CDN). -->
<link rel="stylesheet" href="/css/style.css?v=20260618"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style> <style>
/* ── Compat V3: token + bottoni emessi dal JS (ex style.css, niente CDN) ── */
:root{
--primary:#1F5FA8;--gray-100:#F1F5F9;--gray-200:#E3E9F0;--gray-400:#94A3B8;--gray-500:#64748B;
}
.btn{display:inline-flex;align-items:center;gap:7px;border:1px solid transparent;border-radius:8px;padding:8px 13px;font:inherit;font-weight:600;font-size:12.5px;cursor:pointer;line-height:1.2}
.btn:disabled{opacity:.55;cursor:not-allowed}
.btn-primary{background:var(--c-blue);color:#fff}.btn-primary:hover:not(:disabled){background:#1A5494}
.btn-outline{background:#fff;color:var(--ink);border-color:var(--line)}.btn-outline:hover:not(:disabled){background:#F5F8FC;border-color:#c7d4e4}
.btn-sm{padding:5px 11px;font-size:11.5px}
a.btn{text-decoration:none}
.pc-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; } .pc-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; }
.pc-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; } .pc-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
.pc-toolbar { display:flex; justify-content:flex-end; margin-bottom:12px; } .pc-toolbar { display:flex; justify-content:flex-end; margin-bottom:12px; }
@@ -50,16 +61,23 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content">
<header class="content-header"> <main class="wrap">
<h2 data-i18n="pctl.title">Controlli periodici</h2> <nav class="crumb" aria-label="breadcrumb">
<div class="content-header-actions"> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<button class="btn btn-primary btn-sm" onclick="pcOpen()">+ Nuovo controllo</button> <i data-lucide="chevron-right" class="ic"></i><span>Monitoraggio</span>
</div> <i data-lucide="chevron-right" class="ic"></i><span data-i18n="pctl.title">Controlli periodici</span>
</header> </nav>
<div class="content-body"> <div class="page-head">
<div>
<h1 data-i18n="pctl.title">Controlli periodici</h1>
<p class="sub">Controlli ricorrenti, esecuzioni con evidenza ed esiti (ISO/IEC 27001 §9.1 / A.8.16)</p>
</div>
<button class="btn-primary" onclick="pcOpen()"><i data-lucide="plus" class="ic"></i>Nuovo controllo</button>
</div>
<section class="card card-pad">
<div class="pc-intro"> <div class="pc-intro">
<strong>Controlli periodici (monitoraggio).</strong> <strong>Controlli periodici (monitoraggio).</strong>
Definisci i controlli ricorrenti (con responsabile, frequenza e metodo) e registra ogni esecuzione con esito ed evidenze: Definisci i controlli ricorrenti (con responsabile, frequenza e metodo) e registra ogni esecuzione con esito ed evidenze:
@@ -69,9 +87,8 @@
Ancoraggio: <strong>ISO/IEC 27001 §9.1</strong> (monitoraggio e misurazione) e <strong>A.8.16</strong> (attività di monitoraggio) — <strong>buona prassi</strong>, non un obbligo di legge. Per NIS2 supporta GV.PO-02/DE.CM. Strumento di supporto, non un parere legale. Ancoraggio: <strong>ISO/IEC 27001 §9.1</strong> (monitoraggio e misurazione) e <strong>A.8.16</strong> (attività di monitoraggio) — <strong>buona prassi</strong>, non un obbligo di legge. Per NIS2 supporta GV.PO-02/DE.CM. Strumento di supporto, non un parere legale.
</div> </div>
<div id="pc-list" aria-live="polite"></div> <div id="pc-list" aria-live="polite"></div>
</div> </section>
</main> </main>
</div>
<!-- Modale controllo (crea/modifica) --> <!-- Modale controllo (crea/modifica) -->
<div class="sm-overlay" id="pc-modal" role="dialog" aria-modal="true" aria-labelledby="pc-modal-title"> <div class="sm-overlay" id="pc-modal" role="dialog" aria-modal="true" aria-labelledby="pc-modal-title">
@@ -136,12 +153,14 @@
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="/js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → init pagina -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/api.js?v=20260730a"></script>
<script src="/js/common-bi.js?v=20260631"></script> <script src="/js/common.js?v=20260729b"></script>
<script src="/js/i18n.js?v=20260631"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/help.js?v=20260618p2"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
'use strict'; 'use strict';
/* Controlli periodici (control monitoring §9.1/A.8.16): controlli ricorrenti + esecuzioni (evidenza) + NC/AC. */ /* Controlli periodici (control monitoring §9.1/A.8.16): controlli ricorrenti + esecuzioni (evidenza) + NC/AC. */
@@ -155,9 +174,10 @@
document.addEventListener('DOMContentLoaded', async function () { document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return; if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar(); if (typeof loadSidebar === 'function') loadSidebar();
if (window.I18n && I18n.init) I18n.init('it');
if (window.HelpSystem && HelpSystem.init) HelpSystem.init(); if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
document.addEventListener('keydown', e => { if (e.key === 'Escape') { pcClose(); detClose(); execClose(); } }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { pcClose(); detClose(); execClose(); } });
['pc-modal','det-modal','exec-modal'].forEach(m => { const o = el(m); if (o) o.addEventListener('click', e => { if (e.target === o) o.classList.remove('open'); }); }); ['pc-modal','det-modal','exec-modal'].forEach(m => { const o = el(m); if (o) o.addEventListener('click', e => { if (e.target === o) o.classList.remove('open'); }); });
try { const r = await api.orgRolesList(); PC.roles = (r && (r.roles || r)) || []; } catch (e) { PC.roles = []; } try { const r = await api.orgRolesList(); PC.roles = (r && (r.roles || r)) || []; } catch (e) { PC.roles = []; }
+480 -629
View File
File diff suppressed because it is too large Load Diff
+398
View File
@@ -0,0 +1,398 @@
/* ═══════════════════════════════════════════════════════════════════════════
* NIS2 Agile — UI V3 design system
* Portato FEDELMENTE dal <style> del mockup "docs/mockup Ui/index.html".
* Token + chrome (appbar/mega-menu) + componenti (card/stat/calendario/anag/doc/fab).
* Font: Inter self-host (/vendor/inter/inter.css). Icone: Lucide self-host.
* Aggiunte minime (segnate "V3+") per stati loading/empty/error e touch≥44px.
* ═══════════════════════════════════════════════════════════════════════════ */
:root{
--navy-top:#0A2E54; --navy-nav:#103A61; --navy-hover:#1B4A75; --gold:#F0B429;
--bg:#EFF4FA; --card:#FFFFFF; --ink:#15263C; --muted:#64748B; --line:#E3E9F0;
--c-green:#1E8A5A; --c-blue:#1F5FA8; --c-amber:#D98324; --c-red:#C62828;
--radius:12px; --shadow:0 1px 2px rgba(16,40,80,.06),0 8px 24px rgba(16,40,80,.06);
}
*{box-sizing:border-box} html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:'Inter',system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:14px;line-height:1.5}
a{color:inherit;text-decoration:none}
:focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:4px}
/* ── Chrome: appbar ─────────────────────────────────────────────────────── */
.appbar{position:sticky;top:0;z-index:50}
.bar-top{background:var(--navy-top);color:#fff}
.bar-top-in{max-width:1280px;margin:0 auto;display:flex;align-items:center;gap:16px;padding:10px 24px}
.flag{width:18px;height:13px;border-radius:2px;overflow:hidden;display:flex;flex:0 0 auto;box-shadow:0 0 0 1px rgba(255,255,255,.25)}
.flag i{flex:1}.flag .g{background:#1f8a4c}.flag .w{background:#fff}.flag .r{background:#cd2b2b}
.logo-mark{width:34px;height:34px;border-radius:8px;background:var(--c-green);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:800;font-size:13px;flex:0 0 auto}
.brand-txt{line-height:1.1;min-width:0}
.brand-txt b{font-size:15px;font-weight:700}
.brand-txt span{display:block;font-size:10.5px;color:#A9C2DC;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.search{flex:1;max-width:340px;display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.16);border-radius:8px;padding:7px 12px;color:#cfe0f1;min-width:120px}
.search input{flex:1;border:0;background:transparent;color:#fff;font:inherit;outline:none;min-width:0}
.search input::placeholder{color:#9db8d2}
.spacer{flex:1}
.btn-incident{display:inline-flex;align-items:center;gap:8px;background:#D24545;color:#fff;border:0;border-radius:8px;padding:9px 15px;font:inherit;font-weight:600;font-size:13px;cursor:pointer;box-shadow:0 1px 2px rgba(120,20,20,.35);white-space:nowrap}
.btn-incident:hover{background:#BF3A3A}
.btn-incident .ic{width:16px;height:16px}
.user{display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.16);border-radius:8px;padding:5px 10px 5px 5px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
.user:hover{background:rgba(255,255,255,.16)}
.avatar{width:24px;height:24px;border-radius:6px;background:var(--c-green);color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700}
/* ── Chrome: nav + mega-menu ────────────────────────────────────────────── */
.bar-nav{background:var(--navy-nav);border-top:1px solid rgba(255,255,255,.06)}
.bar-nav-in{max-width:1280px;margin:0 auto;padding:0 16px;display:flex;align-items:stretch;gap:2px;flex-wrap:wrap}
.nav-item{position:relative}
.nav-link{display:flex;align-items:center;gap:7px;color:#cfe0f1;font-weight:600;font-size:13px;padding:13px 14px;white-space:nowrap;border-bottom:3px solid transparent;cursor:pointer}
.nav-link .ic{width:15px;height:15px;opacity:.9}
.nav-link .caret{width:13px;height:13px;opacity:.7;margin-left:-2px}
.nav-item:hover>.nav-link{color:#fff;background:rgba(255,255,255,.06)}
.nav-link.active{color:#fff;border-bottom-color:var(--gold)}
.mega{position:absolute;top:100%;left:0;background:#fff;color:var(--ink);border-radius:0 0 14px 14px;box-shadow:0 18px 40px rgba(11,40,80,.18);border:1px solid var(--line);border-top:0;padding:22px;display:none;grid-template-columns:auto auto 250px;gap:30px;min-width:560px;z-index:60}
.nav-item:hover .mega,.nav-item:focus-within .mega{display:grid}
.nav-item:nth-last-child(-n+3) .mega{left:auto;right:0}
.mega.no-promo{min-width:auto}
.nav-item:hover .mega.no-promo,.nav-item:focus-within .mega.no-promo{display:flex;gap:30px;width:auto}
.mega-col{min-width:190px}
.mega-head{font-size:10.5px;font-weight:700;letter-spacing:.12em;color:#94A3B8;text-transform:uppercase;margin:0 0 12px}
.mega-link{display:flex;gap:11px;padding:8px;border-radius:9px;align-items:flex-start}
.mega-link:hover{background:#F3F7FC}
.mega-link .mega-ic{width:36px;height:36px;border-radius:10px;background:#EAF1FA;color:var(--c-blue);display:flex;align-items:center;justify-content:center;flex:0 0 auto}
.mega-ic .ic{width:21px;height:21px;display:block}
.mega-link b{display:block;font-size:13.5px;font-weight:700}
.mega-link span{display:block;font-size:11.5px;color:var(--muted);margin-top:2px}
.promo{background:linear-gradient(160deg,#1F5FA8,#0F3F77);color:#fff;border-radius:12px;padding:18px;display:flex;flex-direction:column;gap:10px}
.promo .badge{align-self:flex-start;background:rgba(255,255,255,.18);font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;padding:4px 9px;border-radius:999px}
.promo b{font-size:16px}
.promo p{margin:0;font-size:12px;color:#cfe0f3;line-height:1.45}
.promo .go{margin-top:auto;align-self:flex-end;width:38px;height:38px;border-radius:9px;background:#fff;color:var(--c-blue);display:flex;align-items:center;justify-content:center}
/* ── Layout / page head ─────────────────────────────────────────────────── */
.wrap{max-width:1280px;margin:0 auto;padding:22px 24px 80px}
.crumb{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);margin-bottom:14px}
.crumb .ic{width:13px;height:13px}.crumb a:hover{color:var(--ink)}
.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:24px;flex-wrap:wrap}
.page-head h1{margin:0;font-size:26px;font-weight:800;letter-spacing:-.4px}
.page-head .sub{margin:4px 0 0;color:var(--muted);font-size:13.5px}
.date{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;background:#fff;border:1px solid var(--line);border-radius:9px;padding:8px 12px}
.date .ic{width:15px;height:15px}
/* ── Card / stack ───────────────────────────────────────────────────────── */
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow)}
.card-pad{padding:20px}
.stack{display:grid;gap:18px}
/* ── Calendario (dashboard widgets) ─────────────────────────────────────── */
.cal-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;flex-wrap:wrap}
.cal-head h3{margin:0;font-size:15px;font-weight:700}
.cal-ctrl{display:flex;gap:6px;align-items:center}
.ctrl-btn{width:32px;height:32px;border:1px solid var(--line);border-radius:8px;background:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer}
.ctrl-btn:hover{background:#F5F8FC}
.ctrl-btn .ic{width:16px;height:16px;color:var(--muted)}
.ctrl-today{height:32px;padding:0 13px;border:1px solid var(--line);border-radius:8px;background:#fff;font:inherit;font-weight:600;font-size:12px;display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--ink)}
.ctrl-today:hover{background:#F5F8FC}
.ctrl-today .ic{width:14px;height:14px;color:var(--c-blue)}
.daystrip{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px;scrollbar-width:thin}
.daycell{flex:1 1 0;min-width:64px;border:1px solid var(--line);border-radius:11px;background:#fff;padding:11px 6px 9px;text-align:center;position:relative;cursor:pointer;transition:border-color .12s,transform .12s,box-shadow .12s}
.daycell:hover{border-color:#9FBEDD;box-shadow:0 4px 12px rgba(16,40,80,.08)}
.daycell .wd{font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#8093A8}
.daycell .dn{font-size:21px;font-weight:800;line-height:1.15;margin-top:3px}
.daycell .mo{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-top:1px}
.daycell.weekend{background:#F5F8FC}
.daycell.weekend .wd{color:#A6B4C6}
.daycell.today{background:var(--navy-nav);border-color:var(--navy-nav)}
.daycell.today:hover{border-color:var(--navy-hover);box-shadow:0 6px 16px rgba(16,40,80,.22)}
.daycell.today .wd{color:#C7D9EC}.daycell.today .dn,.daycell.today .mo{color:#fff}
.daycell.today::before{content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--gold);border-radius:11px 11px 0 0}
.dots{display:flex;gap:3px;justify-content:center;margin-top:7px;min-height:6px}
.ev{width:6px;height:6px;border-radius:50%;display:inline-block}
.ev.green{background:var(--c-green)}.ev.blue{background:var(--c-blue)}.ev.amber{background:var(--c-amber)}.ev.red{background:var(--c-red)}
.weekstrip{display:grid;grid-template-columns:1fr 1.25fr 1fr;gap:14px;align-items:start}
.weekblock{border:1px solid var(--line);border-radius:12px;background:#FAFCFE;padding:13px;cursor:pointer;transition:border-color .12s,box-shadow .12s}
.weekblock:hover{border-color:#9FBEDD;box-shadow:0 6px 16px rgba(16,40,80,.10)}
.weekblock.current{background:#fff;border-color:#B9CFE6;box-shadow:0 6px 18px rgba(16,40,80,.08)}
.weekblock .wl{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:8px}
.weekblock .wl b{font-size:12.5px;font-weight:700;color:var(--muted)}
.weekblock.current .wl b{color:var(--c-blue)}
.weekblock .wl span{font-size:9.5px;color:#A6B4C6;text-transform:uppercase;letter-spacing:.07em;font-weight:700;white-space:nowrap}
.week7{display:grid;grid-template-columns:repeat(7,1fr);gap:3px}
.mini{text-align:center;border-radius:8px;padding:6px 0 5px}
.mini .mi-wd{font-size:9.5px;font-weight:700;color:#9aa8ba;text-transform:uppercase}
.mini .mi-dn{font-size:13px;font-weight:700;margin-top:1px}
.mini.weekend .mi-wd,.mini.weekend .mi-dn{color:#A6B4C6}
.mini.today{background:var(--navy-nav)}.mini.today .mi-wd{color:#C7D9EC}.mini.today .mi-dn{color:#fff}
.mini .mi-ev{display:flex;gap:2px;justify-content:center;min-height:5px;margin-top:3px}
.mini .mi-ev .ev{width:4px;height:4px}
.cal-pop{position:fixed;z-index:80;display:none;width:252px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 14px 36px rgba(11,40,80,.20);padding:13px 14px;pointer-events:none;opacity:0;transform:translateY(5px);transition:opacity .12s ease,transform .12s ease}
.cal-pop.show{opacity:1;transform:none}
.cal-pop h4{margin:0 0 9px;font-size:12.5px;font-weight:700;text-transform:capitalize}
.cal-pop .pe{display:flex;gap:9px;align-items:flex-start;padding:6px 0;border-top:1px solid #EFF3F8}
.cal-pop .pe:first-of-type{border-top:0;padding-top:0}
.cal-pop .pe .ev{margin-top:5px;flex:0 0 auto}
.cal-pop .pe .tt{font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums;display:block}
.cal-pop .pe b{font-size:12px;font-weight:600;line-height:1.3;display:block}
.cal-pop .empty{font-size:12px;color:#94A3B8}
/* ── FAB ────────────────────────────────────────────────────────────────── */
.fab-stack{position:fixed;right:22px;bottom:22px;display:flex;flex-direction:column;gap:12px;z-index:40}
.fab{width:46px;height:46px;border-radius:50%;border:0;color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 8px 20px rgba(16,40,80,.22)}
.fab .ic{width:20px;height:20px}
.fab.blue{background:var(--c-blue)}.fab.red{background:var(--c-red)}.fab.violet{background:#7C4DD6}
/* ── Grid helpers ───────────────────────────────────────────────────────── */
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.split2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
.split-anag{display:grid;grid-template-columns:1.35fr 1fr;gap:18px}
.sec-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
.sec-head h3{margin:0;font-size:15px;font-weight:700}
.sec-head .hint{font-size:11.5px;color:var(--muted)}
/* ── Stat cards ─────────────────────────────────────────────────────────── */
.stat{display:flex;gap:14px;align-items:flex-start;border-left:4px solid var(--c-blue);transition:box-shadow .12s,transform .12s}
a.stat{color:inherit}
a.stat:hover{box-shadow:0 8px 20px rgba(16,40,80,.13);transform:translateY(-1px)}
.stat.green{border-left-color:var(--c-green)}.stat.amber{border-left-color:var(--c-amber)}.stat.blue{border-left-color:var(--c-blue)}
.stat .ibox{width:42px;height:42px;border-radius:11px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;background:#EAF1FA;color:var(--c-blue)}
.stat.green .ibox{background:#E6F4EC;color:var(--c-green)}.stat.amber .ibox{background:#FBF1E2;color:var(--c-amber)}
.stat .ibox .ic{width:20px;height:20px}
.stat .lbl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#7a8aa0}
.stat .val{font-size:27px;font-weight:800;line-height:1.1;margin:3px 0 1px}
.stat.green .val{color:var(--c-green)}.stat.amber .val{color:var(--c-amber)}.stat.blue .val{color:var(--c-blue)}
.stat .sub{font-size:12px;color:var(--muted)}
.bar{height:7px;background:#EAF0F6;border-radius:99px;overflow:hidden;margin-top:9px;max-width:180px}
.bar>i{display:block;height:100%;background:var(--c-amber);border-radius:99px}
/* ── Buttons ────────────────────────────────────────────────────────────── */
.btn-primary{display:inline-flex;align-items:center;gap:7px;background:var(--c-blue);color:#fff;border:0;border-radius:8px;padding:8px 13px;font:inherit;font-weight:600;font-size:12.5px;cursor:pointer}
.btn-primary:hover{background:#1A5494}
.btn-primary .ic{width:15px;height:15px}
.btn-ghost{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:7px 12px;font:inherit;font-weight:600;font-size:12.5px;color:var(--ink);cursor:pointer}
.btn-ghost:hover{background:#F5F8FC;border-color:#c7d4e4}
.btn-ghost .ic{width:15px;height:15px;color:var(--c-blue)}
/* ── Scadenze (deadlines) ───────────────────────────────────────────────── */
.dpill{flex:0 0 auto;width:46px;text-align:center;background:#EFF4FB;border:1px solid var(--line);border-radius:9px;padding:6px 0}
.dpill b{display:block;font-size:15px;font-weight:800;line-height:1}
.dpill span{font-size:9.5px;font-weight:700;letter-spacing:.05em;color:var(--muted);text-transform:uppercase}
.sca{display:flex;gap:13px;align-items:flex-start;padding:12px 0;border-top:1px solid var(--line)}
.sca:first-of-type{border-top:0;padding-top:0}
.sca .body{flex:1;min-width:0}
.sca .body b{font-size:13px;font-weight:600;display:block}
.meta{display:flex;gap:8px;align-items:center;margin-top:5px;flex-wrap:wrap}
.who{font-size:11.5px;color:var(--muted)}
/* ── Badge ──────────────────────────────────────────────────────────────── */
.badge-t{font-size:10px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;padding:2px 7px;border-radius:6px;background:#EEF2F7;color:#5b6b80;white-space:nowrap}
.badge-t.blue{background:#E7F0FB;color:#1F5FA8}.badge-t.amber{background:#FBF1E2;color:#9A6418}.badge-t.red{background:#FBE9E9;color:#B23636}.badge-t.green{background:#E6F4EC;color:#1E7A4E}.badge-t.gray{background:#EEF2F7;color:#5b6b80}
/* ── Attività (open activities) ─────────────────────────────────────────── */
.act-scroll{max-height:330px;overflow-y:auto;padding-right:4px;scrollbar-width:thin}
.act{display:flex;gap:12px;align-items:flex-start;justify-content:space-between;padding:12px;border:1px solid var(--line);border-radius:10px;margin-bottom:8px;background:#fff}
.act:last-child{margin-bottom:0}
.act .body{flex:1;min-width:0}.act .body b{font-size:13px;font-weight:600;display:block}
/* ── Anagrafica ─────────────────────────────────────────────────────────── */
.anag{display:flex;gap:16px;align-items:flex-start}
.anag-logo{width:60px;height:60px;border-radius:13px;background:linear-gradient(135deg,#143A5C,#1F5FA8);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:17px;letter-spacing:.5px;flex:0 0 auto}
.anag .info{flex:1;min-width:0}
.anag .nm{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.anag .nm h3{margin:0;font-size:16px;font-weight:800}
.badge-nis{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:6px;background:#E7F0FB;color:#1F5FA8}
.anag-grid{display:grid;grid-template-columns:1fr 1fr;gap:11px 18px;margin-top:14px}
.anag-grid .f span{display:block;font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#8093A8}
.anag-grid .f b{font-size:13px;font-weight:600}
/* ── Documenti istituzionali ────────────────────────────────────────────── */
.doc{display:flex;align-items:center;gap:12px;width:100%;text-align:left;border:1px solid var(--line);background:#fff;border-radius:10px;padding:12px 14px;cursor:pointer;font:inherit;margin-bottom:10px}
.doc:last-child{margin-bottom:0}
.doc:hover{border-color:#c7d4e4;background:#FAFCFE}
.doc .di{width:36px;height:36px;border-radius:9px;background:#EAF1FA;color:var(--c-blue);display:flex;align-items:center;justify-content:center;flex:0 0 auto}
.doc .di .ic{width:18px;height:18px}
.doc .dt{flex:1;min-width:0}
.doc .dt b{font-size:13.5px;font-weight:700;display:block}
.doc .dt small{font-size:11px;color:var(--muted)}
.doc .st{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-weight:700;padding:5px 10px;border-radius:999px;white-space:nowrap}
.doc .st .ic{width:14px;height:14px}
.doc.has .st{background:#E6F4EC;color:#1E7A4E}
.doc.miss .st{background:#FBF1E2;color:#9A6418}
/* ── V3+ : stati loading / empty / error (dati reali via api.js) ────────── */
.v3-loading{display:flex;align-items:center;justify-content:center;gap:10px;padding:28px 12px;color:var(--muted);font-size:13px}
.v3-spin{width:18px;height:18px;border:2px solid var(--line);border-top-color:var(--c-blue);border-radius:50%;animation:v3spin .7s linear infinite}
@keyframes v3spin{to{transform:rotate(360deg)}}
.v3-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:26px 12px;color:var(--muted);text-align:center}
.v3-empty .ic{width:26px;height:26px;opacity:.6}
.v3-empty b{font-size:13px;font-weight:700;color:var(--ink)}
.v3-empty span{font-size:12px}
.v3-error{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:26px 12px;color:var(--c-red);text-align:center}
.v3-error .ic{width:26px;height:26px}
.v3-error b{font-size:13px;font-weight:700}
.v3-error span{font-size:12px;color:var(--muted)}
@media (prefers-reduced-motion:reduce){.v3-spin{animation:none}}
/* ── V3+ : mobile nav toggle (hamburger) ────────────────────────────────── */
.nav-toggle{display:none;align-items:center;justify-content:center;width:38px;height:38px;border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.08);border-radius:8px;color:#fff;cursor:pointer;flex:0 0 auto}
.nav-toggle .ic{width:20px;height:20px}
/* ── Responsive (dal mockup) + V3+ mobile nav ──────────────────────────── */
@media (max-width:900px){
.weekstrip{grid-template-columns:1fr}
.grid3,.split2,.split-anag{grid-template-columns:1fr}
.mega{grid-template-columns:1fr;min-width:280px}
.search{display:none}
.nav-toggle{display:flex}
.bar-nav-in{display:none;flex-direction:column;align-items:stretch;padding:6px 12px 10px}
.bar-nav.open .bar-nav-in{display:flex}
.nav-item{position:static}
.nav-link{border-bottom:0;padding:11px 8px}
.nav-link.active{background:rgba(255,255,255,.06);border-radius:8px;border-left:3px solid var(--gold)}
.nav-link .caret{margin-left:auto}
.mega,.mega.no-promo{position:static;display:none!important;box-shadow:none;border:0;border-radius:0;padding:0 0 8px 18px;min-width:auto}
.nav-item.open .mega,.nav-item.open .mega.no-promo{display:flex!important;flex-direction:column;gap:0}
.mega-col{min-width:auto}
}
@media (max-width:560px){
.brand-txt span{display:none}
.btn-incident span{display:none}
.wrap{padding:16px 14px 80px}
}
/* ── V3+ : touch target ≥44px su pointer coarse (AGID/WCAG) ─────────────── */
@media (pointer:coarse){
.nav-link,.ctrl-today,.btn-primary,.btn-ghost,.btn-incident,.user,.doc{min-height:44px}
.ctrl-btn{min-width:44px;min-height:44px}
.fab{width:52px;height:52px}
}
/* ═══════════════════════════════════════════════════════════════════════════
* V3 — WIDGET CONDIVISI (compat) — modali/toast/form/bottoni/spinner/progress.
* Generati da common.js (showModal/showNotification) e dal markup di varie
* pagine: vivevano in style.css (rimosso dalle pagine V3). Qui sono ri-portati
* UNA volta sola e ritematizzati sui colori V3. NON ridefinisce .btn-primary /
* .btn-ghost / .badge-t (già in v3.css sopra): nessun conflitto.
* ═════════════════════════════════════════════════════════════════════════ */
:root{
/* alias V2→V3 usati dai widget sottostanti */
--primary:#1F5FA8; --primary-dark:#16487f; --primary-light:#3b7bc4; --primary-bg:#eaf1f9;
--secondary:#1E8A5A; --secondary-light:#27a06b; --secondary-bg:#e3f3ea;
--warning:#D98324; --warning-light:#e89a45; --warning-bg:#fbeede;
--danger:#C62828; --danger-light:#e05656; --danger-bg:#fbe5e5;
--info:#1F5FA8; --info-bg:#eaf1f9;
--gray-50:#f8fafc; --gray-100:#f1f5f9; --gray-200:#e2e8f0; --gray-300:#cbd5e1;
--gray-400:#94a3b8; --gray-500:#64748b; --gray-600:#475569; --gray-700:#334155;
--gray-800:#1e293b; --gray-900:#0f172a;
--card-bg:var(--card); --border-radius:10px; --border-radius-lg:var(--radius); --border-radius-sm:6px;
--transition:250ms ease; --transition-fast:150ms ease;
}
/* Bottoni (varianti non presenti in v3.css; self-sufficient, non toccano primary/ghost) */
.btn-secondary,.btn-success,.btn-danger,.btn-warning,.btn-outline{
display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 18px;
font-size:.875rem;font-weight:600;font-family:inherit;border:1px solid transparent;
border-radius:var(--radius);cursor:pointer;text-decoration:none;line-height:1.4;white-space:nowrap;
transition:background .15s ease,border-color .15s ease,color .15s ease}
.btn-secondary{background:var(--gray-100);color:var(--gray-700);border-color:var(--gray-300)}
.btn-secondary:hover:not(:disabled){background:var(--gray-200);border-color:var(--gray-400)}
.btn-success{background:var(--secondary);color:#fff;border-color:var(--secondary)}
.btn-success:hover:not(:disabled){background:var(--secondary-light)}
.btn-danger{background:var(--danger);color:#fff;border-color:var(--danger)}
.btn-danger:hover:not(:disabled){background:var(--danger-light)}
.btn-warning{background:var(--warning);color:#1a1a1a;border-color:var(--warning)}
.btn-outline{background:transparent;color:var(--navy-nav);border-color:var(--navy-nav)}
.btn-outline:hover:not(:disabled){background:var(--navy-nav);color:#fff}
.btn-secondary:disabled,.btn-success:disabled,.btn-danger:disabled,.btn-warning:disabled,.btn-outline:disabled{opacity:.5;cursor:not-allowed}
.btn-sm{padding:6px 14px;font-size:.8rem}
/* Documenti istituzionali (dashboard) — ticket #499 */
.doc-list{display:flex;flex-direction:column;gap:2px}
.doc-item{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:10px 4px;border-bottom:1px solid var(--v3-border,#e6e8ee)}
.doc-item:last-of-type{border-bottom:none}
.doc-main{display:flex;flex-direction:column;gap:2px;min-width:0}
.doc-main b{font-size:.92rem}
.doc-desc{font-size:.82rem;color:var(--v3-muted,#5b6472);white-space:pre-line;word-break:break-word}
.doc-empty-desc{font-style:italic;opacity:.75}
.doc-file{font-size:.78rem;font-weight:500;margin-left:6px}
.doc-actions{display:flex;gap:2px;flex:0 0 auto}
.doc-add{margin-top:8px}
.btn-lg{padding:14px 28px;font-size:1rem}
.btn-icon{padding:8px;width:36px;height:36px}
.btn-group{display:flex;gap:8px}
/* Form */
.form-group{margin-bottom:20px}
.form-label{display:block;font-size:.8125rem;font-weight:600;color:var(--gray-700);margin-bottom:6px}
.form-label .required{color:var(--danger);margin-left:2px}
.form-group .form-label{position:static;line-height:1.4;padding:0;width:auto;max-width:none;z-index:auto;background-color:transparent;cursor:default}
.form-input,.form-select,.form-textarea{width:100%;padding:10px 14px;font-size:.875rem;font-family:inherit;color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:var(--border-radius);transition:border-color var(--transition-fast),box-shadow var(--transition-fast);outline:none}
.form-input:focus,.form-select:focus,.form-textarea:focus{border-color:var(--navy-nav);box-shadow:0 0 0 3px rgba(31,95,168,.15)}
.form-input::placeholder,.form-textarea::placeholder{color:var(--gray-400)}
.form-input.error,.form-select.error,.form-textarea.error{border-color:var(--danger);box-shadow:0 0 0 3px rgba(198,40,40,.12)}
.form-textarea{resize:vertical;min-height:100px}
.form-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%2364748b' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:36px}
.form-help{font-size:.75rem;color:var(--gray-500);margin-top:6px}
/* Badge generici (style.css legacy) — coesistono con .badge-t di v3.css */
.badge{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;font-size:.7rem;font-weight:600;border-radius:100px;text-transform:uppercase;letter-spacing:.03em;white-space:nowrap}
.badge-primary{background:var(--primary-bg);color:var(--primary)}
.badge-success{background:var(--secondary-bg);color:#15803d}
.badge-warning{background:var(--warning-bg);color:#a16207}
.badge-danger{background:var(--danger-bg);color:var(--danger)}
.badge-info{background:var(--info-bg);color:var(--primary)}
.badge-neutral{background:var(--gray-100);color:var(--gray-600)}
.badge-lg{padding:6px 16px;font-size:.8rem}
.tag{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;font-size:.75rem;font-weight:500;background:var(--gray-100);color:var(--gray-600);border-radius:var(--border-radius-sm);border:1px solid var(--gray-200)}
/* Progress */
.progress{width:100%;height:8px;background:var(--gray-200);border-radius:100px;overflow:hidden}
.progress-bar{height:100%;border-radius:100px;transition:width .8s ease;background:var(--navy-nav)}
.progress-bar.success{background:var(--secondary)}
.progress-bar.warning{background:var(--warning)}
.progress-bar.danger{background:var(--danger)}
.progress-lg{height:12px}
.progress-label{display:flex;justify-content:space-between;margin-bottom:6px;font-size:.8rem}
.progress-label span:first-child{font-weight:600;color:var(--gray-700)}
.progress-label span:last-child{color:var(--gray-500)}
/* Modal (showModal di common.js) */
.modal-overlay{position:fixed;inset:0;width:100%;height:100%;background:rgba(15,23,42,.6);backdrop-filter:blur(4px);z-index:1000;display:flex;align-items:center;justify-content:center;opacity:0;visibility:hidden;transition:all var(--transition)}
.modal-overlay.active{opacity:1;visibility:visible}
.modal{background:var(--card);border-radius:var(--border-radius-lg);box-shadow:0 20px 60px rgba(0,0,0,.2);width:90%;max-width:560px;max-height:85vh;overflow-y:auto;transform:translateY(20px) scale(.95);transition:transform var(--transition)}
.modal-overlay.active .modal{transform:translateY(0) scale(1)}
.modal-overlay .modal{display:block;position:relative;inset:auto;margin:0;z-index:auto;pointer-events:auto}
.modal-header{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}
.modal-header h3{font-size:1.125rem;font-weight:700;color:var(--ink)}
.modal-close{background:none;border:none;color:var(--gray-400);cursor:pointer;padding:4px;border-radius:var(--border-radius-sm);transition:color var(--transition-fast);line-height:1;font-size:1.4rem}
.modal-close:hover{color:var(--gray-700)}
.modal-body{padding:24px}
.modal-footer{padding:16px 24px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:8px}
/* Notifiche / toast (showNotification di common.js) */
.notification-container{position:fixed;top:20px;right:20px;z-index:2000;display:flex;flex-direction:column;gap:8px;pointer-events:none}
.notification{background:var(--card);border-radius:var(--border-radius);box-shadow:0 8px 24px rgba(0,0,0,.15);padding:14px 20px;display:flex;align-items:center;gap:12px;min-width:320px;max-width:480px;pointer-events:auto;animation:slideIn .3s ease;border-left:4px solid var(--gray-400)}
.notification.success{border-left-color:var(--secondary)}
.notification.error{border-left-color:var(--danger)}
.notification.warning{border-left-color:var(--warning)}
.notification.info{border-left-color:var(--primary)}
.notification-icon{width:20px;height:20px;flex-shrink:0}
.notification.success .notification-icon{color:var(--secondary)}
.notification.error .notification-icon{color:var(--danger)}
.notification.warning .notification-icon{color:var(--warning)}
.notification.info .notification-icon{color:var(--primary)}
.notification-message{flex:1;font-size:.875rem;color:var(--gray-700);font-weight:500}
.notification-close{background:none;border:none;color:var(--gray-400);cursor:pointer;padding:2px;font-size:1.1rem;line-height:1}
.notification.fade-out{animation:slideOut .3s ease forwards}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
@keyframes slideOut{from{transform:translateX(0);opacity:1}to{transform:translateX(100%);opacity:0}}
/* Spinner */
.spinner{width:24px;height:24px;border:3px solid var(--gray-200);border-top-color:var(--navy-nav);border-radius:50%;animation:spin .7s linear infinite;margin:0 auto}
.spinner-lg{width:40px;height:40px;border-width:4px}
@keyframes spin{to{transform:rotate(360deg)}}
/* Utility generiche (ex Bootstrap Italia/style.css) usate da markup di varie pagine.
* .btn base SENZA padding → non altera il padding di .btn-primary/.btn-ghost (v3) ; i
* variant (sm/secondary/outline/...) restano self-sufficient. */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;font-family:inherit;font-weight:600;border:1px solid transparent;border-radius:var(--radius);cursor:pointer;text-decoration:none;line-height:1.4;white-space:nowrap;transition:background .15s ease,border-color .15s ease,color .15s ease}
.btn:disabled{opacity:.5;cursor:not-allowed}
.text-muted{color:var(--muted)}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
:root{ --success:var(--c-green); --error:var(--c-red); }
+108 -327
View File
@@ -3,12 +3,10 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Dashboard - NIS2 Agile</title> <title>Dashboard · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg CDN). -->
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
via JS (card/gauge/stat/modali) -> parita' funzionale assoluta. BI veste la chrome. --> <link rel="stylesheet" href="/css/v3.css?v=20260724a">
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC"> <meta name="theme-color" content="#0066CC">
@@ -24,338 +22,121 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<!-- Sidebar --> <div id="topnav"></div>
<aside class="sidebar" id="sidebar"></aside>
<!-- Main Content --> <main class="wrap">
<main class="main-content"> <nav class="crumb" aria-label="breadcrumb">
<header class="content-header"> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<h1 class="h2" data-i18n="dashboard.title">Dashboard</h1> <i data-lucide="chevron-right" class="ic"></i><span>Dashboard</span>
<div class="content-header-actions"> </nav>
<span class="text-muted" id="header-date"></span> <div class="page-head">
</div> <div>
</header> <h1 data-i18n="dashboard.title">Dashboard</h1>
<p class="sub">Stato di conformità NIS2 del tuo perimetro</p>
<div class="content-body">
<!-- Compliance Score + Stats -->
<div class="grid-3 mb-24" style="grid-template-columns: 1fr 2fr;">
<!-- Gauge -->
<div class="card">
<div class="card-body text-center">
<div id="compliance-gauge">
<div class="spinner-lg" style="margin:40px auto;"></div>
</div>
<p class="text-muted mt-8" style="font-size:0.8rem;">Avanzamento implementazione misure Art.21 NIS2</p>
<p class="text-muted" style="font-size:0.7rem; margin-top:2px;">Misura l'implementazione tecnica, non la conformita' legale</p>
</div>
</div>
<!-- Stats Cards -->
<div>
<div class="stats-grid" id="stats-grid">
<div class="stat-card">
<div class="stat-card-icon danger">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd"/></svg>
</div>
<div class="stat-card-content">
<h4>Rischi Aperti</h4>
<div class="stat-card-value" id="stat-risks">--</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon warning">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z"/></svg>
</div>
<div class="stat-card-content">
<h4>Incidenti Attivi</h4>
<div class="stat-card-value" id="stat-incidents">--</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon success">
<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>
</div>
<div class="stat-card-content">
<h4>Policy Approvate</h4>
<div class="stat-card-value" id="stat-policies">--</div>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon primary">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838L7.667 9.088l1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zM9.3 16.573A9.026 9.026 0 007 14.935v-3.957l1.818.78a3 3 0 002.364 0l5.508-2.361a11.026 11.026 0 01.25 3.762 1 1 0 01-.89.89 8.968 8.968 0 00-5.35 2.524 1 1 0 01-1.4 0z"/></svg>
</div>
<div class="stat-card-content">
<h4>Formazione</h4>
<div class="stat-card-value" id="stat-training">--%</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card mb-24" id="nis2-dashboard-quick-actions">
<div class="card-header">
<h3>Azioni Rapide</h3>
</div>
<div class="card-body">
<div class="quick-actions">
<button class="quick-action-btn" id="nis2-dashboard-qa-assessment" onclick="window.location.href='assessment.html'">
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Nuovo Assessment
</button>
<button class="quick-action-btn" id="nis2-dashboard-qa-incidente" onclick="window.location.href='incidents.html'">
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6z"/></svg>
Registra Incidente
</button>
<button class="quick-action-btn" onclick="generateAIPolicy()">
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
Genera Policy AI
</button>
</div>
</div>
</div>
<!-- Deadlines + Activity -->
<div class="grid-2">
<!-- Scadenze -->
<div class="card">
<div class="card-header">
<h3>Prossime Scadenze</h3>
</div>
<div class="card-body" id="deadlines-list">
<div class="spinner" style="margin:20px auto;"></div>
</div>
</div>
<!-- Attivita' Recenti -->
<div class="card">
<div class="card-header">
<h3>Attivita' Recenti</h3>
</div>
<div class="card-body" id="activity-list">
<div class="spinner" style="margin:20px auto;"></div>
</div>
</div>
</div>
</div> </div>
</main> <div class="date"><i data-lucide="calendar" class="ic"></i><span id="header-date"></span></div>
</div> </div>
<script src="/js/api.js?v=20260631"></script> <div class="stack">
<script src="/js/common.js?v=20260631"></script> <!-- Monitor Giornaliero (eventi reali: scadenze + attività) -->
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <section class="card card-pad">
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, <div class="cal-head">
riusando tutti gli helper di common.js (zero backend). --> <h3>Monitor Giornaliero</h3>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <div class="cal-ctrl">
<script> <button class="ctrl-btn" id="d-prev" title="Giorni precedenti" aria-label="Giorni precedenti"><i data-lucide="chevron-left" class="ic"></i></button>
if (window.bootstrap && bootstrap.loadFonts) { <button class="ctrl-today" id="d-today"><i data-lucide="calendar-check" class="ic"></i>Oggi</button>
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); <button class="ctrl-btn" id="d-next" title="Giorni successivi" aria-label="Giorni successivi"><i data-lucide="chevron-right" class="ic"></i></button>
} </div>
</script> </div>
<script src="/js/common-bi.js?v=20260631"></script> <div class="daystrip" id="daystrip"></div>
<script src="/js/i18n.js?v=20260631"></script> </section>
<script src="/js/help.js?v=20260618p2"></script>
<!-- Monitor Settimanale -->
<section class="card card-pad">
<div class="cal-head">
<h3>Monitor Settimanale</h3>
<div class="cal-ctrl">
<button class="ctrl-btn" id="w-prev" title="Settimana precedente" aria-label="Settimana precedente"><i data-lucide="chevron-left" class="ic"></i></button>
<button class="ctrl-today" id="w-today"><i data-lucide="calendar-check" class="ic"></i>Questa settimana</button>
<button class="ctrl-btn" id="w-next" title="Settimana successiva" aria-label="Settimana successiva"><i data-lucide="chevron-right" class="ic"></i></button>
</div>
</div>
<div class="weekstrip" id="weekstrip"></div>
</section>
<!-- 3 stat card (dati reali) -->
<div class="grid3">
<a href="assessment.html" class="card card-pad stat blue" id="stat-gap">
<div class="ibox"><i data-lucide="git-compare" class="ic"></i></div>
<div style="flex:1;min-width:0"><div class="lbl">Gap analysis in corso</div><div class="val">…</div><div class="sub">caricamento…</div></div>
</a>
<a href="training.html" class="card card-pad stat green" id="stat-training">
<div class="ibox"><i data-lucide="graduation-cap" class="ic"></i></div>
<div style="flex:1;min-width:0"><div class="lbl">Formazione</div><div class="val">…</div><div class="sub">caricamento…</div></div>
</a>
<a href="risks.html" class="card card-pad stat amber" id="stat-risk">
<div class="ibox"><i data-lucide="shield-check" class="ic"></i></div>
<div style="flex:1;min-width:0"><div class="lbl">Rischi valutati</div><div class="val">…</div><div class="sub">caricamento…</div><div class="bar"><i style="width:0%"></i></div></div>
</a>
</div>
<!-- Scadenze + Attività aperte -->
<div class="split2">
<section class="card card-pad">
<div class="sec-head"><h3>Scadenze · prossimi 10 giorni</h3><span class="hint" id="sca-hint"></span></div>
<div id="sca-list"><div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div></div>
</section>
<section class="card card-pad">
<div class="sec-head"><h3>Attività aperte</h3>
<a class="btn-primary" href="stakeholder-activities.html"><i data-lucide="plus" class="ic"></i>Nuova attività</a></div>
<div class="act-scroll" id="act-scroll"><div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div></div>
</section>
</div>
<!-- Anagrafica + Documenti istituzionali -->
<div class="split-anag">
<section class="card card-pad">
<div class="sec-head"><h3>Anagrafica aziendale</h3>
<a class="btn-ghost" href="settings.html"><i data-lucide="pencil" class="ic"></i>Modifica</a></div>
<div id="anag-card"><div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div></div>
</section>
<section class="card card-pad">
<div class="sec-head"><h3>Documenti istituzionali</h3></div>
<div id="doc-card"></div>
</section>
</div>
</div>
</main>
<!-- Scripts V3: api.js (client REST, prima) → common.js (helper+FAB reali) →
Lucide self-host → topnav-v3.js (chrome, ridefinisce loadSidebar) →
i18n → help → dashboard.js -->
<script src="/js/api.js?v=20260730a"></script>
<script src="/js/common.js?v=20260729b"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script src="/js/dashboard.js?v=20260825"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
// ── Init ───────────────────────────────────────────────── // ── Init chrome + i18n + help ────────────────────────────
loadSidebar(); loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
I18n.init(); if (typeof I18n !== 'undefined') I18n.init();
HelpSystem.init(); if (typeof HelpSystem !== 'undefined') HelpSystem.init();
// Data corrente nell'header // Data corrente nell'header
document.getElementById('header-date').textContent = new Date().toLocaleDateString('it-IT', { var hd = document.getElementById('header-date');
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' if (hd) hd.textContent = new Date().toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
});
// ── Caricamento dati ───────────────────────────────────── // Icone Lucide (chrome + pagina)
loadDashboard(); if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
async function loadDashboard() { // ── Dati reali ───────────────────────────────────────────
try { if (typeof initDashboard === 'function') initDashboard();
const result = await api.getDashboardOverview();
if (result.success && result.data) {
const data = result.data;
// Compliance gauge
const score = data.compliance_score != null ? Math.round(data.compliance_score) : 0;
document.getElementById('compliance-gauge').innerHTML = renderScoreGauge(score, 180);
// Stats - map backend response structure to UI
const openRisks = data.risks ? (parseInt(data.risks.total) || 0) : 0;
const activeIncidents = data.active_incidents || 0;
const approvedPolicies = Array.isArray(data.policies)
? data.policies.reduce((sum, p) => p.status === 'approved' || p.status === 'published' ? sum + parseInt(p.count) : sum, 0)
: 0;
const trainingTotal = data.training ? (parseInt(data.training.total) || 0) : 0;
const trainingCompleted = data.training ? (parseInt(data.training.completed) || 0) : 0;
const trainingPct = trainingTotal > 0 ? Math.round((trainingCompleted / trainingTotal) * 100) : 0;
document.getElementById('stat-risks').textContent = openRisks;
document.getElementById('stat-incidents').textContent = activeIncidents;
document.getElementById('stat-policies').textContent = approvedPolicies;
document.getElementById('stat-training').textContent = trainingPct + '%';
}
} catch (err) {
console.error('Dashboard load error:', err);
}
// Always load deadlines and activity from dedicated endpoints
loadIndividualData();
}
async function loadIndividualData() {
// Compliance score
try {
const scoreRes = await api.getComplianceScore();
if (scoreRes.success && scoreRes.data) {
// Preferisci la % controlli implementati; se 0/assente (org con gap
// analysis fatta ma senza modulo controlli) ripiega sul punteggio assessment.
const d = scoreRes.data;
const score = (d.avg_implementation && d.avg_implementation > 0) ? d.avg_implementation : (d.score || 0);
document.getElementById('compliance-gauge').innerHTML = renderScoreGauge(score, 180);
} else {
document.getElementById('compliance-gauge').innerHTML = renderScoreGauge(0, 180);
}
} catch (e) {
document.getElementById('compliance-gauge').innerHTML = renderScoreGauge(0, 180);
}
// Deadlines
try {
const dlRes = await api.getUpcomingDeadlines();
renderDeadlines(dlRes.success ? (dlRes.data || []) : []);
} catch (e) {
renderDeadlines([]);
}
// Activity
try {
const actRes = await api.getRecentActivity();
renderActivity(actRes.success ? (actRes.data || []) : []);
} catch (e) {
renderActivity([]);
}
}
function renderDeadlines(deadlines) {
const container = document.getElementById('deadlines-list');
if (!deadlines || deadlines.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"/></svg>
<h4>Nessuna scadenza imminente</h4>
<p>Le prossime scadenze appariranno qui.</p>
</div>`;
return;
}
const months = ['GEN','FEB','MAR','APR','MAG','GIU','LUG','AGO','SET','OTT','NOV','DIC'];
let html = '';
deadlines.slice(0, 5).forEach(dl => {
const d = new Date(dl.due_date || dl.date);
const now = new Date();
const diffDays = Math.ceil((d - now) / 86400000);
let urgency = 'normal';
if (diffDays <= 3) urgency = 'urgent';
else if (diffDays <= 7) urgency = 'soon';
html += `
<div class="deadline-item">
<div class="deadline-date ${urgency}">
<span class="day">${d.getDate()}</span>
<span class="month">${months[d.getMonth()]}</span>
</div>
<div class="deadline-info">
<div class="deadline-title">${escapeHtml(dl.title || dl.description || 'Scadenza')}</div>
<div class="deadline-desc">${escapeHtml(dl.category || dl.type || '')}</div>
</div>
${diffDays <= 3 ? '<span class="badge badge-danger">Urgente</span>' : diffDays <= 7 ? '<span class="badge badge-warning">Prossima</span>' : ''}
</div>`;
});
container.innerHTML = html;
}
function renderActivity(activities) {
const container = document.getElementById('activity-list');
if (!activities || activities.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
<h4>Nessuna attivita' recente</h4>
<p>Le attivita' appariranno qui quando inizierai ad usare la piattaforma.</p>
</div>`;
return;
}
let html = '<ul class="activity-list">';
activities.slice(0, 8).forEach(act => {
html += `
<li class="activity-item">
<div class="activity-icon">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
</div>
<div class="activity-content">
<div class="activity-text">${escapeHtml(act.description || act.message || act.action || '')}</div>
<div class="activity-time">${timeAgo(act.created_at || act.timestamp || act.date)}</div>
</div>
</li>`;
});
html += '</ul>';
container.innerHTML = html;
}
async function generateAIPolicy() {
showModal('Genera Policy con AI', `
<p style="margin-bottom:16px;">Seleziona la categoria per cui generare una policy automatica tramite intelligenza artificiale.</p>
<div class="form-group">
<label class="form-label">Categoria Policy</label>
<select class="form-select" id="ai-policy-category">
<option value="risk_management">Gestione del Rischio</option>
<option value="incident_response">Risposta agli Incidenti</option>
<option value="business_continuity">Continuita' Operativa</option>
<option value="supply_chain">Sicurezza Supply Chain</option>
<option value="access_control">Controllo degli Accessi</option>
<option value="encryption">Crittografia</option>
<option value="hr_security">Sicurezza del Personale</option>
<option value="asset_management">Gestione degli Asset</option>
</select>
</div>
`, {
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="doGeneratePolicy()">Genera</button>
`
});
}
async function doGeneratePolicy() {
const category = document.getElementById('ai-policy-category').value;
closeModal();
showNotification('Generazione policy in corso...', 'info');
try {
const result = await api.aiGeneratePolicy(category);
if (result.success) {
showNotification('Policy generata con successo!', 'success');
setTimeout(() => window.location.href = 'policies.html', 1500);
} else {
showNotification(result.message || 'Errore nella generazione.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
</script> </script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -73,7 +73,7 @@
</script> </script>
<!-- Stessa logica della forgot-password.html: ZERO modifiche backend --> <!-- Stessa logica della forgot-password.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260631"></script> <script src="js/api.js?v=20260730a"></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');
+132 -89
View File
@@ -3,12 +3,42 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Guida all'uso - NIS2 Agile</title> <title>Guida all'uso · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
cosi' style.css resta autorevole per la chrome (sidebar/widget). BI veste la guida. --> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<style> <style>
/* ── V3: card di base + accordion self-contained (ex Bootstrap Italia, ZERO CDN) ── */
.content-body .card,.card-bg{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow)}
.content-body .card .card-body{padding:20px}
.mb-4{margin-bottom:1.5rem} .mb-0{margin-bottom:0} .mt-3{margin-top:1rem} .mt-4{margin-top:1.5rem}
.text-center{text-align:center} .text-muted{color:var(--muted)} .text-uppercase{text-transform:uppercase}
.h6{font-size:.85rem;font-weight:700} .shadow-sm{box-shadow:var(--shadow)}
.alert{padding:14px 18px;border-radius:8px;font-size:.92rem}
.alert-warning{background:#FBF1E2;border:1px solid #EBD9B4;color:#7a5310}
/* Accordion */
.accordion{display:flex;flex-direction:column;gap:10px}
.accordion-item{border:1px solid var(--line);border-radius:10px;background:var(--card);overflow:hidden;box-shadow:var(--shadow)}
.accordion-header{margin:0}
.accordion-button{display:flex;align-items:center;width:100%;border:0;background:#fff;
padding:16px 18px;font:inherit;font-size:15px;font-weight:700;color:var(--ink);cursor:pointer;text-align:left}
.accordion-button:hover{background:#F5F8FC}
.accordion-button::after{content:"";margin-left:auto;width:10px;height:10px;border-right:2px solid var(--muted);
border-bottom:2px solid var(--muted);transform:rotate(-45deg);transition:transform .18s;flex:0 0 auto}
.accordion-button:not(.collapsed)::after{transform:rotate(45deg)}
.accordion-collapse{display:none}
.accordion-collapse.show{display:block}
.accordion-body{padding:8px 20px 22px}
.accordion-body h3{font-size:1.05rem;margin:18px 0 8px;color:var(--ink)}
.accordion-body ul,.accordion-body ol{padding-left:20px} .accordion-body li{margin:4px 0}
:root{ --c-num:#06c; }
/* Lucide inline (ex sprite BI box-icon): dimensione coerente al testo */
.box-icon{width:1.05em;height:1.05em;vertical-align:-3px;margin-right:6px;display:inline-block}
.plain-box .box-icon,.callout-tip .box-icon{color:#06c}
.example-box .box-icon{color:#a66300}
.norm-box .box-icon{color:#5a6772}
.alert-warning .box-icon{color:#a16207}
/* ── Stili specifici guida (callout normativi/esempi) ──────── */ /* ── Stili specifici guida (callout normativi/esempi) ──────── */
.guide-wrap { max-width: 940px; margin: 0 auto; } .guide-wrap { max-width: 940px; margin: 0 auto; }
@@ -103,16 +133,20 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content"> <main class="wrap">
<header class="content-header"> <nav class="crumb" aria-label="breadcrumb">
<h1 class="h2">Guida all'uso di NIS2 Agile</h1> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<div class="content-header-actions"> <i data-lucide="chevron-right" class="ic"></i><span>Guida all'uso</span>
<span class="text-muted">Per chi inizia da zero</span> </nav>
</div> <div class="page-head">
</header> <div>
<h1>Guida all'uso di NIS2 Agile</h1>
<p class="sub">Per chi inizia da zero — parole semplici, esempi pratici, norma</p>
</div>
</div>
<div class="content-body"> <div class="content-body">
<div class="guide-wrap"> <div class="guide-wrap">
@@ -127,7 +161,7 @@
un <strong style="color:#7a4a00;">esempio pratico</strong> e <strong style="color:#5a6772;">cosa dice la norma</strong>. un <strong style="color:#7a4a00;">esempio pratico</strong> e <strong style="color:#5a6772;">cosa dice la norma</strong>.
</p> </p>
<div class="alert alert-warning mt-3 mb-0" role="note"> <div class="alert alert-warning mt-3 mb-0" role="note">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning-circle"></use></svg> <i data-lucide="alert-triangle" class="box-icon" aria-hidden="true"></i>
<strong>Avvertenza.</strong> Questa guida ha finalità operative e divulgative: spiega come usare la <strong>Avvertenza.</strong> Questa guida ha finalità operative e divulgative: spiega come usare la
piattaforma e riassume in modo semplificato la normativa. <strong>Non costituisce un parere legale</strong> piattaforma e riassume in modo semplificato la normativa. <strong>Non costituisce un parere legale</strong>
e non sostituisce la consulenza di un professionista né le indicazioni ufficiali dell'ACN (Agenzia per la e non sostituisce la consulenza di un professionista né le indicazioni ufficiali dell'ACN (Agenzia per la
@@ -143,7 +177,7 @@
<nav class="card card-bg shadow-sm mb-4 guide-toc-card" aria-label="Indice"> <nav class="card card-bg shadow-sm mb-4 guide-toc-card" aria-label="Indice">
<div class="card-body"> <div class="card-body">
<h2 class="h6 text-uppercase mb-3" style="letter-spacing:.05em;"> <h2 class="h6 text-uppercase mb-3" style="letter-spacing:.05em;">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-list"></use></svg> <i data-lucide="list" class="box-icon" aria-hidden="true"></i>
Indice Indice
</h2> </h2>
<ul class="guide-toc-list"> <ul class="guide-toc-list">
@@ -180,7 +214,7 @@
<div id="body-1" class="accordion-collapse collapse show" role="region" aria-labelledby="head-1" data-bs-parent="#guide-accordion"> <div id="body-1" class="accordion-collapse collapse show" role="region" aria-labelledby="head-1" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> NIS2 è una legge europea (Direttiva UE 2022/2555, recepita in Italia <strong>In parole semplici.</strong> NIS2 è una legge europea (Direttiva UE 2022/2555, recepita in Italia
con il D.Lgs. 138/2024) che obbliga molte aziende a proteggere bene i propri sistemi informatici. con il D.Lgs. 138/2024) che obbliga molte aziende a proteggere bene i propri sistemi informatici.
Lo scopo è evitare che attacchi hacker, fughe di dati o blocchi dei sistemi danneggino cittadini, Lo scopo è evitare che attacchi hacker, fughe di dati o blocchi dei sistemi danneggino cittadini,
@@ -194,7 +228,7 @@
<li><strong>Controllare i fornitori</strong> che hanno accesso ai propri sistemi (Art. 21, lettera d).</li> <li><strong>Controllare i fornitori</strong> che hanno accesso ai propri sistemi (Art. 21, lettera d).</li>
</ol> </ol>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio reale.</strong> Una clinica privata con 250 dipendenti è "in scope" NIS2 perché <strong>Esempio reale.</strong> Una clinica privata con 250 dipendenti è "in scope" NIS2 perché
opera nel settore sanitario (settore essenziale). Se subisce un ransomware che blocca le cartelle cliniche opera nel settore sanitario (settore essenziale). Se subisce un ransomware che blocca le cartelle cliniche
per 8 ore, deve notificarlo al CSIRT Italia entro 24 ore con una prima segnalazione e poi entro 72 ore per 8 ore, deve notificarlo al CSIRT Italia entro 24 ore con una prima segnalazione e poi entro 72 ore
@@ -220,7 +254,7 @@
<div id="body-2" class="accordion-collapse collapse" role="region" aria-labelledby="head-2" data-bs-parent="#guide-accordion"> <div id="body-2" class="accordion-collapse collapse" role="region" aria-labelledby="head-2" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> "In scope" vuol dire che la legge si applica a te. Dipende da <strong>In parole semplici.</strong> "In scope" vuol dire che la legge si applica a te. Dipende da
<em>cosa fai</em> (settore) e <em>quanto sei grande</em> (dipendenti e fatturato). <em>cosa fai</em> (settore) e <em>quanto sei grande</em> (dipendenti e fatturato).
</div> </div>
@@ -265,7 +299,7 @@
<li>la <strong>Pubblica Amministrazione</strong> nei casi previsti.</li> <li>la <strong>Pubblica Amministrazione</strong> nei casi previsti.</li>
</ul> </ul>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-help-circle"></use></svg> <i data-lucide="help-circle" class="box-icon" aria-hidden="true"></i>
<strong>Caso reale: Agile Technology.</strong> Una software house che eroga a terzi una piattaforma SaaS <strong>Caso reale: Agile Technology.</strong> Una software house che eroga a terzi una piattaforma SaaS
e servizi ICT gestiti (come NIS2 Agile stessa) rientra tipicamente tra i <strong>fornitori di servizi e servizi ICT gestiti (come NIS2 Agile stessa) rientra tipicamente tra i <strong>fornitori di servizi
gestiti B2B</strong>: in questo caso è in ambito <em>a prescindere dal numero di dipendenti o dal gestiti B2B</strong>: in questo caso è in ambito <em>a prescindere dal numero di dipendenti o dal
@@ -273,7 +307,7 @@
l'esenzione "piccola impresa" qui non si applica. l'esenzione "piccola impresa" qui non si applica.
</div> </div>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio pratico.</strong> "Aurora Sanità S.p.A." con 480 dipendenti e 92 milioni € di fatturato, <strong>Esempio pratico.</strong> "Aurora Sanità S.p.A." con 480 dipendenti e 92 milioni € di fatturato,
settore sanitario → <strong>essenziale + grande</strong> → pienamente in scope. Deve registrarsi sul portale settore sanitario → <strong>essenziale + grande</strong> → pienamente in scope. Deve registrarsi sul portale
ACN (Agenzia per la Cybersicurezza Nazionale) entro le scadenze. ACN (Agenzia per la Cybersicurezza Nazionale) entro le scadenze.
@@ -313,7 +347,7 @@
esterni (Microsoft 365, Google Workspace, AWS, Azure, Identity Provider, EDR, SIEM, ticketing): sono le esterni (Microsoft 365, Google Workspace, AWS, Azure, Identity Provider, EDR, SIEM, ticketing): sono le
fonti da cui la piattaforma raccoglie automaticamente le evidenze di conformità.</p> fonti da cui la piattaforma raccoglie automaticamente le evidenze di conformità.</p>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg> <i data-lucide="lock" class="box-icon" aria-hidden="true"></i>
<strong>Sicurezza dei segreti.</strong> Nella scheda inserisci solo i parametri non sensibili (es. <strong>Sicurezza dei segreti.</strong> Nella scheda inserisci solo i parametri non sensibili (es.
tenant id, client id). Le <strong>credenziali segrete</strong> non vengono mai salvate nel prodotto: tenant id, client id). Le <strong>credenziali segrete</strong> non vengono mai salvate nel prodotto:
sono custodite separatamente nel vault cifrato. Dopo il salvataggio la piattaforma indica il sono custodite separatamente nel vault cifrato. Dopo il salvataggio la piattaforma indica il
@@ -345,7 +379,7 @@
<li><strong>consultant</strong> — gestisce <em>più aziende clienti</em> (sezioni "Aziende" e "Connettori").</li> <li><strong>consultant</strong> — gestisce <em>più aziende clienti</em> (sezioni "Aziende" e "Connettori").</li>
</ul> </ul>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>Nota.</strong> Le sezioni <strong>Aziende</strong> e <strong>Connettori</strong> sono pensate per <strong>Nota.</strong> Le sezioni <strong>Aziende</strong> e <strong>Connettori</strong> sono pensate per
consulenti/studi che gestiscono più clienti: se sei un utente singolo potresti non vederle. consulenti/studi che gestiscono più clienti: se sei un utente singolo potresti non vederle.
</div> </div>
@@ -366,7 +400,7 @@
<li><strong>Genera il report esecutivo</strong> dalla sezione Report — utile per il board.</li> <li><strong>Genera il report esecutivo</strong> dalla sezione Report — utile per il board.</li>
</ol> </ol>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-clock"></use></svg> <i data-lucide="clock" class="box-icon" aria-hidden="true"></i>
<strong>Tempi indicativi.</strong> Un primo ciclo "decente" si fa in 6–8 settimane di lavoro <strong>Tempi indicativi.</strong> Un primo ciclo "decente" si fa in 6–8 settimane di lavoro
spalmate, con 2 persone (un IT e un compliance). Non puntare alla perfezione subito: meglio spalmate, con 2 persone (un IT e un compliance). Non puntare alla perfezione subito: meglio
coprire <em>tutte</em> le 10 categorie al 50% che 3 al 100% e 7 a zero. coprire <em>tutte</em> le 10 categorie al 50% che 3 al 100% e 7 a zero.
@@ -377,7 +411,7 @@
(top 25%, sopra/sotto la mediana) e lo scarto rispetto alla media. È un dato che i tool tradizionali (top 25%, sopra/sotto la mediana) e lo scarto rispetto alla media. È un dato che i tool tradizionali
non possono offrire, perché serve una rete multi-azienda.</p> non possono offrire, perché serve una rete multi-azienda.</p>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg> <i data-lucide="lock" class="box-icon" aria-hidden="true"></i>
<strong>Privacy.</strong> Il confronto è <strong>anonimo e aggregato</strong>: appare solo se nel <strong>Privacy.</strong> Il confronto è <strong>anonimo e aggregato</strong>: appare solo se nel
tuo settore ci sono almeno 3 organizzazioni, e non mostra mai i dati di una singola azienda. tuo settore ci sono almeno 3 organizzazioni, e non mostra mai i dati di una singola azienda.
</div> </div>
@@ -395,7 +429,7 @@
<div id="body-5" class="accordion-collapse collapse" role="region" aria-labelledby="head-5" data-bs-parent="#guide-accordion"> <div id="body-5" class="accordion-collapse collapse" role="region" aria-labelledby="head-5" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> L'Art. 21 elenca 10 famiglie di "cose che devi avere". <strong>In parole semplici.</strong> L'Art. 21 elenca 10 famiglie di "cose che devi avere".
La Gap Analysis ti fa domande per ognuna e calcola quanto sei attrezzato. Non serve essere La Gap Analysis ti fa domande per ognuna e calcola quanto sei attrezzato. Non serve essere
perfetti — serve <strong>sapere dove sei messo male</strong> e <strong>aver iniziato a migliorare</strong>. perfetti — serve <strong>sapere dove sei messo male</strong> e <strong>aver iniziato a migliorare</strong>.
@@ -433,7 +467,7 @@
critici. Anche SMS è meglio di nulla, app authenticator è meglio di SMS, hardware token (FIDO2) critici. Anche SMS è meglio di nulla, app authenticator è meglio di SMS, hardware token (FIDO2)
è il top.</p></div> è il top.</p></div>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio Aurora Sanità.</strong> Punteggio iniziale 58%. I gap principali sono in <strong>Esempio Aurora Sanità.</strong> Punteggio iniziale 58%. I gap principali sono in
Supply Chain (40%) e Crittografia (50%). Piano di azione: assessment dei 12 fornitori critici Supply Chain (40%) e Crittografia (50%). Piano di azione: assessment dei 12 fornitori critici
entro 6 mesi, attivare TLS 1.3 ovunque e cifratura at-rest sui DB cartelle cliniche entro 9 mesi. entro 6 mesi, attivare TLS 1.3 ovunque e cifratura at-rest sui DB cartelle cliniche entro 9 mesi.
@@ -462,7 +496,7 @@
<strong>+6 misure e +29 requisiti</strong> rispetto agli importanti.</li> <strong>+6 misure e +29 requisiti</strong> rispetto agli importanti.</li>
</ul> </ul>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio (requisiti di governance).</strong> <code>GV.RR-04</code> richiede che la cybersicurezza <strong>Esempio (requisiti di governance).</strong> <code>GV.RR-04</code> richiede che la cybersicurezza
sia integrata nella gestione delle risorse umane: il <strong>personale autorizzato ad accedere</strong> sia integrata nella gestione delle risorse umane: il <strong>personale autorizzato ad accedere</strong>
ai sistemi rilevanti e gli <strong>amministratori di sistema</strong> devono essere individuati previa ai sistemi rilevanti e gli <strong>amministratori di sistema</strong> devono essere individuati previa
@@ -472,7 +506,7 @@
comunicata e applicata. comunicata e applicata.
</div> </div>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-help-circle"></use></svg> <i data-lucide="help-circle" class="box-icon" aria-hidden="true"></i>
<strong>Come si collega alla piattaforma.</strong> Le <strong>80 domande</strong> della Gap Analysis <strong>Come si collega alla piattaforma.</strong> Le <strong>80 domande</strong> della Gap Analysis
(8 × 10 categorie Art. 21) danno il quadro di maturità di primo livello; le <strong>specifiche di base (8 × 10 categorie Art. 21) danno il quadro di maturità di primo livello; le <strong>specifiche di base
ACN</strong> (Allegati 1–2) sono il dettaglio operativo verso cui portare l'implementazione. Il modulo ACN</strong> (Allegati 1–2) sono il dettaglio operativo verso cui portare l'implementazione. Il modulo
@@ -499,7 +533,7 @@
<div id="body-5b" class="accordion-collapse collapse" role="region" aria-labelledby="head-5b" data-bs-parent="#guide-accordion"> <div id="body-5b" class="accordion-collapse collapse" role="region" aria-labelledby="head-5b" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> La Gap Analysis dell'Art. 21 (cap. 5) ti dice a grandi linee <strong>In parole semplici.</strong> La Gap Analysis dell'Art. 21 (cap. 5) ti dice a grandi linee
dove sei messo sulle 10 famiglie di misure. Ma in Italia il metro di conformità vero è la dove sei messo sulle 10 famiglie di misure. Ma in Italia il metro di conformità vero è la
<strong>Determinazione ACN n. 164179 del 14/04/2025</strong>, che declina quelle famiglie in <strong>Determinazione ACN n. 164179 del 14/04/2025</strong>, che declina quelle famiglie in
@@ -532,7 +566,7 @@
(cita solo requisiti effettivamente esistenti).</li> (cita solo requisiti effettivamente esistenti).</li>
</ol> </ol>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-help-circle"></use></svg> <i data-lucide="help-circle" class="box-icon" aria-hidden="true"></i>
<strong>Quale uso quando.</strong> La Gap Analysis Art. 21 (cap. 5) è un primo orientamento. La Gap Analysis <strong>Quale uso quando.</strong> La Gap Analysis Art. 21 (cap. 5) è un primo orientamento. La Gap Analysis
ACN è la verifica di conformità puntuale richiesta in concreto: usala per prepararti all'autovalutazione ACN. ACN è la verifica di conformità puntuale richiesta in concreto: usala per prepararti all'autovalutazione ACN.
Fonte: Agenzia per la Cybersicurezza Nazionale, Determinazione n. 164179 del 14/04/2025 (Allegati 1 e 2). Fonte: Agenzia per la Cybersicurezza Nazionale, Determinazione n. 164179 del 14/04/2025 (Allegati 1 e 2).
@@ -551,7 +585,7 @@
<div id="body-6" class="accordion-collapse collapse" role="region" aria-labelledby="head-6" data-bs-parent="#guide-accordion"> <div id="body-6" class="accordion-collapse collapse" role="region" aria-labelledby="head-6" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> Il "registro dei rischi" è un elenco di cose brutte che <strong>In parole semplici.</strong> Il "registro dei rischi" è un elenco di cose brutte che
<em>potrebbero</em> accadere, con quanto è probabile e quanto farebbe male. Per ognuna decidi <em>potrebbero</em> accadere, con quanto è probabile e quanto farebbe male. Per ognuna decidi
cosa fare: ridurla, accettarla, assicurarla, o eliminarla. cosa fare: ridurla, accettarla, assicurarla, o eliminarla.
@@ -572,7 +606,7 @@
<li><strong>Evitare</strong>: smetto di fare l'attività che genera il rischio.</li> <li><strong>Evitare</strong>: smetto di fare l'attività che genera il rischio.</li>
</ul> </ul>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio.</strong> Rischio "Ransomware su sistema PACS" (Aurora Sanità) → probabilità 4, <strong>Esempio.</strong> Rischio "Ransomware su sistema PACS" (Aurora Sanità) → probabilità 4,
impatto 5 → punteggio inerente 20 (critico). Trattamento: mitigazione = MFA su RDP + backup impatto 5 → punteggio inerente 20 (critico). Trattamento: mitigazione = MFA su RDP + backup
immutabili + patch mensili. Dopo le misure: probabilità 2, impatto 4 → punteggio residuo 8 (medio). immutabili + patch mensili. Dopo le misure: probabilità 2, impatto 4 → punteggio residuo 8 (medio).
@@ -601,7 +635,7 @@
piattaforma esegue una <strong>simulazione Monte Carlo</strong> e restituisce la piattaforma esegue una <strong>simulazione Monte Carlo</strong> e restituisce la
<strong>Perdita Annua Attesa (ALE)</strong> con i percentili P10 / P50 / P90 e un istogramma.</p> <strong>Perdita Annua Attesa (ALE)</strong> con i percentili P10 / P50 / P90 e un istogramma.</p>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio.</strong> Ransomware: 0,5–2 attacchi/anno (probabile 1), vulnerabilità 0,3, <strong>Esempio.</strong> Ransomware: 0,5–2 attacchi/anno (probabile 1), vulnerabilità 0,3,
perdita 50k–2M € (probabile 300k). Risultato: ALE media ≈ 175.000 € l'anno. Un numero così parla perdita 50k–2M € (probabile 300k). Risultato: ALE media ≈ 175.000 € l'anno. Un numero così parla
alla Direzione meglio di "rischio alto" e aiuta a giustificare gli investimenti di sicurezza. alla Direzione meglio di "rischio alto" e aiuta a giustificare gli investimenti di sicurezza.
@@ -609,7 +643,7 @@
<p>Il <strong>Registro Quantitativo</strong> somma l'ALE di tutti i rischi quantificati, dando <p>Il <strong>Registro Quantitativo</strong> somma l'ALE di tutti i rischi quantificati, dando
l'esposizione economica complessiva del portafoglio.</p> l'esposizione economica complessiva del portafoglio.</p>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-chart-line"></use></svg> <i data-lucide="trending-up" class="box-icon" aria-hidden="true"></i>
<strong>Perché serve quantificare in euro.</strong> L'ALE va sempre confrontata con il <em>costo</em> <strong>Perché serve quantificare in euro.</strong> L'ALE va sempre confrontata con il <em>costo</em>
della non-conformità, non solo con il costo delle misure. Le sanzioni NIS2 (art. 34 Direttiva (UE) della non-conformità, non solo con il costo delle misure. Le sanzioni NIS2 (art. 34 Direttiva (UE)
2022/2555, recepito dal D.Lgs. 138/2024) arrivano fino a <strong>10 milioni € o il 2% del fatturato 2022/2555, recepito dal D.Lgs. 138/2024) arrivano fino a <strong>10 milioni € o il 2% del fatturato
@@ -624,7 +658,7 @@
due soglie (attenzione e critica): la piattaforma calcola da sola lo stato a <strong>semaforo</strong> due soglie (attenzione e critica): la piattaforma calcola da sola lo stato a <strong>semaforo</strong>
– verde, ambra, rosso – in base al valore e alla direzione (se valori alti o bassi sono peggio).</p> – verde, ambra, rosso – in base al valore e alla direzione (se valori alti o bassi sono peggio).</p>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio.</strong> KRI "% endpoint senza MFA": soglia attenzione 10%, critica 25%. Valore <strong>Esempio.</strong> KRI "% endpoint senza MFA": soglia attenzione 10%, critica 25%. Valore
attuale 18% → semaforo <strong>ambra</strong>. Sale a 30% → <strong>rosso</strong>: intervento prioritario. attuale 18% → semaforo <strong>ambra</strong>. Sale a 30% → <strong>rosso</strong>: intervento prioritario.
</div> </div>
@@ -642,7 +676,7 @@
<div id="body-7" class="accordion-collapse collapse" role="region" aria-labelledby="head-7" data-bs-parent="#guide-accordion"> <div id="body-7" class="accordion-collapse collapse" role="region" aria-labelledby="head-7" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> Se hai un incidente "significativo" devi avvisare il CSIRT <strong>In parole semplici.</strong> Se hai un incidente "significativo" devi avvisare il CSIRT
(la squadra nazionale di risposta cyber) in tre tappe: un preallarme entro 24 ore, una (la squadra nazionale di risposta cyber) in tre tappe: un preallarme entro 24 ore, una
notifica entro 72 ore, e una relazione finale entro un mese. notifica entro 72 ore, e una relazione finale entro un mese.
@@ -660,7 +694,7 @@
e 4 (essenziali) della Determina ACN 164179/2025</strong> (e, per i fornitori digitali, dal Reg. (UE) e 4 (essenziali) della Determina ACN 164179/2025</strong> (e, per i fornitori digitali, dal Reg. (UE)
2024/2690) e variano per tipologia di soggetto.</p> 2024/2690) e variano per tipologia di soggetto.</p>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning-circle"></use></svg> <i data-lucide="alert-triangle" class="box-icon" aria-hidden="true"></i>
<strong>Indicatori usati dalla piattaforma per il triage</strong> (non sono soglie di legge, ma <strong>Indicatori usati dalla piattaforma per il triage</strong> (non sono soglie di legge, ma
aiutano a capire se sei vicino alla significatività): ≥ 500 utenti colpiti · blocco servizi &gt; 4 ore · aiutano a capire se sei vicino alla significatività): ≥ 500 utenti colpiti · blocco servizi &gt; 4 ore ·
impatto transfrontaliero · attacco intenzionale (ransomware, DDoS, intrusione) · danni diretti impatto transfrontaliero · attacco intenzionale (ransomware, DDoS, intrusione) · danni diretti
@@ -677,7 +711,7 @@
<p>Analisi completa: causa radice, azioni correttive, lezioni apprese, raccomandazioni per il futuro. <p>Analisi completa: causa radice, azioni correttive, lezioni apprese, raccomandazioni per il futuro.
<em>Termine normativo: relazione finale entro <strong>un mese dalla notifica</strong> (cioè dal momento delle 72h), non dall'incidente — art. 23 D.Lgs. 138/2024.</em></p></div> <em>Termine normativo: relazione finale entro <strong>un mese dalla notifica</strong> (cioè dal momento delle 72h), non dall'incidente — art. 23 D.Lgs. 138/2024.</em></p></div>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio.</strong> Aurora Sanità subisce un DDoS sul portale prenotazioni alle 09:00 di lunedì. <strong>Esempio.</strong> Aurora Sanità subisce un DDoS sul portale prenotazioni alle 09:00 di lunedì.
Sono colpiti 8.500 utenti per 4 ore → significativo. Workflow: preallarme entro martedì 09:00 → Sono colpiti 8.500 utenti per 4 ore → significativo. Workflow: preallarme entro martedì 09:00 →
notifica entro giovedì 09:00 → relazione finale entro un mese <em>dalla notifica</em> (giovedì + 1 mese). notifica entro giovedì 09:00 → relazione finale entro un mese <em>dalla notifica</em> (giovedì + 1 mese).
@@ -705,7 +739,7 @@
IS-1…IS-4, gravità) e fa partire i tempi Art. 23. Gli alert duplicati vengono riconosciuti e non IS-1…IS-4, gravità) e fa partire i tempi Art. 23. Gli alert duplicati vengono riconosciuti e non
creano doppioni.</p> creano doppioni.</p>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-help-circle"></use></svg> <i data-lucide="help-circle" class="box-icon" aria-hidden="true"></i>
<strong>Per chi integra.</strong> L'endpoint è <code>POST /api/services/incidents-ingest</code> <strong>Per chi integra.</strong> L'endpoint è <code>POST /api/services/incidents-ingest</code>
con una API key dedicata (scope <code>ingest:incidents</code>). Trasforma il modulo incidenti da con una API key dedicata (scope <code>ingest:incidents</code>). Trasforma il modulo incidenti da
reattivo a proattivo. reattivo a proattivo.
@@ -724,7 +758,7 @@
<div id="body-8" class="accordion-collapse collapse" role="region" aria-labelledby="head-8" data-bs-parent="#guide-accordion"> <div id="body-8" class="accordion-collapse collapse" role="region" aria-labelledby="head-8" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> Una "policy" è un documento scritto che dice <em>come</em> <strong>In parole semplici.</strong> Una "policy" è un documento scritto che dice <em>come</em>
si fa una cosa in azienda. Serve sia per fare bene, sia per dimostrare alle autorità che ci hai pensato. si fa una cosa in azienda. Serve sia per fare bene, sia per dimostrare alle autorità che ci hai pensato.
</div> </div>
@@ -740,7 +774,7 @@
<li>Acceptable Use Policy (per i dipendenti).</li> <li>Acceptable Use Policy (per i dipendenti).</li>
</ol> </ol>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio.</strong> Genera con l'AI la bozza della "Politica di Crittografia": clicchi <strong>Esempio.</strong> Genera con l'AI la bozza della "Politica di Crittografia": clicchi
"Genera con AI", indichi il settore (sanità), l'AI ti propone un documento di 4–5 pagine con "Genera con AI", indichi il settore (sanità), l'AI ti propone un documento di 4–5 pagine con
sezioni standard (algoritmi accettati, gestione chiavi, KMS, audit). Tu rivedi, adatti e fai sezioni standard (algoritmi accettati, gestione chiavi, KMS, audit). Tu rivedi, adatti e fai
@@ -753,7 +787,7 @@
Per ogni policy approvata, i dipendenti registrano la <strong>presa visione</strong> con un clic; la Per ogni policy approvata, i dipendenti registrano la <strong>presa visione</strong> con un clic; la
piattaforma tiene la copertura (es. "32 su 40 hanno preso visione") e mostra chi manca.</p> piattaforma tiene la copertura (es. "32 su 40 hanno preso visione") e mostra chi manca.</p>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check-circle"></use></svg> <i data-lucide="check-circle" class="box-icon" aria-hidden="true"></i>
<strong>Importante.</strong> L'attestation è <strong>legata alla versione</strong>: se aggiorni la <strong>Importante.</strong> L'attestation è <strong>legata alla versione</strong>: se aggiorni la
policy e la riapprovi con una nuova versione, chi aveva firmato la versione precedente deve policy e la riapprovi con una nuova versione, chi aveva firmato la versione precedente deve
prendere visione di nuovo. Così la prova di lettura è sempre sulla versione corrente. prendere visione di nuovo. Così la prova di lettura è sempre sulla versione corrente.
@@ -776,7 +810,7 @@
<div id="body-9" class="accordion-collapse collapse" role="region" aria-labelledby="head-9" data-bs-parent="#guide-accordion"> <div id="body-9" class="accordion-collapse collapse" role="region" aria-labelledby="head-9" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> Se un tuo fornitore IT viene bucato, l'attaccante può entrare <strong>In parole semplici.</strong> Se un tuo fornitore IT viene bucato, l'attaccante può entrare
in casa tua. NIS2 ti obbliga a valutare i fornitori "critici" (quelli con accesso ai tuoi sistemi in casa tua. NIS2 ti obbliga a valutare i fornitori "critici" (quelli con accesso ai tuoi sistemi
o dati sensibili). o dati sensibili).
@@ -791,7 +825,7 @@
<li>Inserire clausole NIS2 nel contratto (right to audit, notifica incidenti, etc.).</li> <li>Inserire clausole NIS2 nel contratto (right to audit, notifica incidenti, etc.).</li>
</ol> </ol>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio.</strong> "MedTech Manutenzione PACS S.r.l." ha accesso remoto al PACS di <strong>Esempio.</strong> "MedTech Manutenzione PACS S.r.l." ha accesso remoto al PACS di
Aurora Sanità → criticità "critical". Risk score iniziale 6/10 (ha ISO 27001, ma sub-appalto Aurora Sanità → criticità "critical". Risk score iniziale 6/10 (ha ISO 27001, ma sub-appalto
non chiaro). Azione: clausola contrattuale che vieta sub-appalto senza autorizzazione e non chiaro). Azione: clausola contrattuale che vieta sub-appalto senza autorizzazione e
@@ -805,7 +839,7 @@
<p>Alla consegna, il sistema calcola in automatico un <strong>punteggio</strong> e aggiorna la scheda <p>Alla consegna, il sistema calcola in automatico un <strong>punteggio</strong> e aggiorna la scheda
del fornitore, così il suo livello di rischio si allinea da solo alle risposte ricevute.</p> del fornitore, così il suo livello di rischio si allinea da solo alle risposte ricevute.</p>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio.</strong> Invii il questionario al provider cloud. Compila il link entro 30 giorni: <strong>Esempio.</strong> Invii il questionario al provider cloud. Compila il link entro 30 giorni:
7 "sì" e 1 "parziale" → punteggio 94/100, requisiti di sicurezza soddisfatti. 7 "sì" e 1 "parziale" → punteggio 94/100, requisiti di sicurezza soddisfatti.
</div> </div>
@@ -826,7 +860,7 @@
duplicarlo: utile per sincronizzazioni periodiche da un CMDB o da un gestionale aziendale. È disponibile anche duplicarlo: utile per sincronizzazioni periodiche da un CMDB o da un gestionale aziendale. È disponibile anche
un'<strong>API key</strong> per l'integrazione automatica dal sistema dell'azienda cliente.</p> un'<strong>API key</strong> per l'integrazione automatica dal sistema dell'azienda cliente.</p>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>Nota interpretativa sul perimetro.</strong> NIS2 non fornisce una lista chiusa di "fornitori <strong>Nota interpretativa sul perimetro.</strong> NIS2 non fornisce una lista chiusa di "fornitori
da censire": l'obbligo (Direttiva (UE) 2022/2555, art. 21.2(d) — sicurezza della catena di da censire": l'obbligo (Direttiva (UE) 2022/2555, art. 21.2(d) — sicurezza della catena di
approvvigionamento) richiede di valutare i fornitori in funzione del rischio che introducono, dando approvvigionamento) richiede di valutare i fornitori in funzione del rischio che introducono, dando
@@ -850,7 +884,7 @@
<div id="body-10" class="accordion-collapse collapse" role="region" aria-labelledby="head-10" data-bs-parent="#guide-accordion"> <div id="body-10" class="accordion-collapse collapse" role="region" aria-labelledby="head-10" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> NIS2 è chiara: <em>gli amministratori (CDA, direttori) <strong>In parole semplici.</strong> NIS2 è chiara: <em>gli amministratori (CDA, direttori)
devono essere formati sulla cybersecurity</em>. Non è un nice-to-have, è obbligatorio. E sono devono essere formati sulla cybersecurity</em>. Non è un nice-to-have, è obbligatorio. E sono
loro che rispondono in prima persona se l'azienda non è conforme. loro che rispondono in prima persona se l'azienda non è conforme.
@@ -863,7 +897,7 @@
almeno annuale.</li> almeno annuale.</li>
</ol> </ol>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio.</strong> Aurora Sanità organizza: 1 corso da 4h per CDA, 1 corso da 16h per il team <strong>Esempio.</strong> Aurora Sanità organizza: 1 corso da 4h per CDA, 1 corso da 16h per il team
IT, una sessione obbligatoria di 1h per tutti i dipendenti più simulazioni di phishing trimestrali. IT, una sessione obbligatoria di 1h per tutti i dipendenti più simulazioni di phishing trimestrali.
Tutto loggato nel modulo Formazione. Tutto loggato nel modulo Formazione.
@@ -896,7 +930,7 @@
<div id="body-11" class="accordion-collapse collapse" role="region" aria-labelledby="head-11" data-bs-parent="#guide-accordion"> <div id="body-11" class="accordion-collapse collapse" role="region" aria-labelledby="head-11" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> Non puoi proteggere quello che non sai di avere. <strong>In parole semplici.</strong> Non puoi proteggere quello che non sai di avere.
L'inventario asset elenca tutti i sistemi, applicazioni, database e infrastrutture critiche. L'inventario asset elenca tutti i sistemi, applicazioni, database e infrastrutture critiche.
</div> </div>
@@ -911,7 +945,7 @@
<li><strong>Facility</strong>: data center, sale server.</li> <li><strong>Facility</strong>: data center, sale server.</li>
</ul> </ul>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio.</strong> Aurora Sanità inserisce: il PACS, il database cartelle cliniche, <strong>Esempio.</strong> Aurora Sanità inserisce: il PACS, il database cartelle cliniche,
il portale prenotazioni. Per ognuno: criticità, vendor, location, owner. Quando arriva un incidente il portale prenotazioni. Per ognuno: criticità, vendor, location, owner. Quando arriva un incidente
o un rischio, può essere associato a uno o più asset → tracciabilità totale. o un rischio, può essere associato a uno o più asset → tracciabilità totale.
@@ -923,7 +957,7 @@
carichi un CSV (o un export dal CMDB/cloud) e la piattaforma crea gli asset calcolando carichi un CSV (o un export dal CMDB/cloud) e la piattaforma crea gli asset calcolando
<strong>automaticamente</strong> il punteggio per ciascuno.</p> <strong>automaticamente</strong> il punteggio per ciascuno.</p>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-files"></use></svg> <i data-lucide="files" class="box-icon" aria-hidden="true"></i>
<strong>Niente duplicati.</strong> Se indichi un identificativo esterno (<code>external_ref</code>), <strong>Niente duplicati.</strong> Se indichi un identificativo esterno (<code>external_ref</code>),
re-importare lo stesso asset lo <strong>aggiorna</strong> invece di duplicarlo: ideale per re-importare lo stesso asset lo <strong>aggiorna</strong> invece di duplicarlo: ideale per
sincronizzazioni periodiche dal CMDB. sincronizzazioni periodiche dal CMDB.
@@ -942,7 +976,7 @@
<div id="body-12" class="accordion-collapse collapse" role="region" aria-labelledby="head-12" data-bs-parent="#guide-accordion"> <div id="body-12" class="accordion-collapse collapse" role="region" aria-labelledby="head-12" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> Quando le autorità (ACN) verranno a chiederti conto della tua <strong>In parole semplici.</strong> Quando le autorità (ACN) verranno a chiederti conto della tua
compliance, devi poter mostrare documenti, evidenze, registri. Qui li trovi tutti. compliance, devi poter mostrare documenti, evidenze, registri. Qui li trovi tutti.
</div> </div>
@@ -964,17 +998,17 @@
<strong>arancio</strong> (evidenza scaduta), <strong>rosso</strong> (non conforme), grigio (non <strong>arancio</strong> (evidenza scaduta), <strong>rosso</strong> (non conforme), grigio (non
monitorato). La copertura indica quanti controlli sono alimentati da evidenze automatiche.</p> monitorato). La copertura indica quanti controlli sono alimentati da evidenze automatiche.</p>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-help-circle"></use></svg> <i data-lucide="help-circle" class="box-icon" aria-hidden="true"></i>
<strong>Perché conta.</strong> Avvicina l'azienda a una compliance <em>continua</em> anziché <strong>Perché conta.</strong> Avvicina l'azienda a una compliance <em>continua</em> anziché
fotografata una volta l'anno: è il modello dei migliori strumenti internazionali. fotografata una volta l'anno: è il modello dei migliori strumenti internazionali.
</div> </div>
<div class="callout-tip"> <div class="callout-tip">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>Prerequisito.</strong> Questa vista si popola man mano che configuri i <strong>Connettori</strong> <strong>Prerequisito.</strong> Questa vista si popola man mano che configuri i <strong>Connettori</strong>
(vedi cap. 3): senza connettori i controlli restano "non monitorati" (grigio) e la copertura è 0%. (vedi cap. 3): senza connettori i controlli restano "non monitorati" (grigio) e la copertura è 0%.
</div> </div>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio.</strong> Pre-audit ISO 27001: genera il report esecutivo, esporta in CSV <strong>Esempio.</strong> Pre-audit ISO 27001: genera il report esecutivo, esporta in CSV
rischi e controlli, scarica l'audit log certificato. L'auditor ha tutto in 5 minuti. rischi e controlli, scarica l'audit log certificato. L'auditor ha tutto in 5 minuti.
</div> </div>
@@ -992,7 +1026,7 @@
<div id="body-13" class="accordion-collapse collapse" role="region" aria-labelledby="head-13" data-bs-parent="#guide-accordion"> <div id="body-13" class="accordion-collapse collapse" role="region" aria-labelledby="head-13" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> Due cose distinte gestite in un unico modulo: la <strong>In parole semplici.</strong> Due cose distinte gestite in un unico modulo: la
<strong>segnalazione anonima di vulnerabilità</strong> (prevista da NIS2, art. 12, divulgazione <strong>segnalazione anonima di vulnerabilità</strong> (prevista da NIS2, art. 12, divulgazione
coordinata) e il <strong>whistleblowing</strong> dei dipendenti (segnalazione di illeciti), coordinata) e il <strong>whistleblowing</strong> dei dipendenti (segnalazione di illeciti),
@@ -1006,7 +1040,7 @@
classificate dall'AI e (opzionalmente) risolte automaticamente.</li> classificate dall'AI e (opzionalmente) risolte automaticamente.</li>
</ul> </ul>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Esempio.</strong> Un dipendente nota che le credenziali admin del CRM sono salvate in <strong>Esempio.</strong> Un dipendente nota che le credenziali admin del CRM sono salvate in
un foglio Excel condiviso. Invia una segnalazione anonima → il responsabile compliance la riceve un foglio Excel condiviso. Invia una segnalazione anonima → il responsabile compliance la riceve
con codice di tracciamento → il dipendente può seguire lo stato senza rivelare la propria identità. con codice di tracciamento → il dipendente può seguire lo stato senza rivelare la propria identità.
@@ -1025,7 +1059,7 @@
<div id="body-14" class="accordion-collapse collapse" role="region" aria-labelledby="head-14" data-bs-parent="#guide-accordion"> <div id="body-14" class="accordion-collapse collapse" role="region" aria-labelledby="head-14" data-bs-parent="#guide-accordion">
<div class="accordion-body"> <div class="accordion-body">
<div class="plain-box"> <div class="plain-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg> <i data-lucide="info" class="box-icon" aria-hidden="true"></i>
<strong>In parole semplici.</strong> La piattaforma usa Claude (Anthropic) per assisterti. L'AI <strong>In parole semplici.</strong> La piattaforma usa Claude (Anthropic) per assisterti. L'AI
è bravissima a partire dal foglio bianco, ma <strong>non sostituisce</strong> il tuo giudizio: è bravissima a partire dal foglio bianco, ma <strong>non sostituisce</strong> il tuo giudizio:
rivedi sempre quello che produce prima di approvarlo. rivedi sempre quello che produce prima di approvarlo.
@@ -1042,7 +1076,7 @@
<li><strong>Classificazione Feedback</strong>: tag automatico per le segnalazioni di bug/UX.</li> <li><strong>Classificazione Feedback</strong>: tag automatico per le segnalazioni di bug/UX.</li>
</ul> </ul>
<div class="example-box"> <div class="example-box">
<svg class="icon icon-sm box-icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-note"></use></svg> <i data-lucide="sticky-note" class="box-icon" aria-hidden="true"></i>
<strong>Buona pratica.</strong> Quando l'AI suggerisce 8 rischi, non importarli tutti ciecamente. <strong>Buona pratica.</strong> Quando l'AI suggerisce 8 rischi, non importarli tutti ciecamente.
Scegli i 4 più rilevanti per il tuo contesto, scartane uno che è duplicato di un asset interno, Scegli i 4 più rilevanti per il tuo contesto, scartane uno che è duplicato di un asset interno,
e personalizza descrizione/probabilità per la tua realtà. e personalizza descrizione/probabilità per la tua realtà.
@@ -1139,47 +1173,56 @@
</div><!-- /guide-wrap --> </div><!-- /guide-wrap -->
</div><!-- /content-body --> </div><!-- /content-body -->
</main> </main>
</div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="/js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help -->
<!-- Bootstrap Italia bundle (accordion) + override sidebar BI (common-bi.js). <script src="/js/api.js?v=20260730a"></script>
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. --> <script src="/js/common.js?v=20260729b"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script> <script src="/js/topnav-v3.js?v=20260729"></script>
if (window.bootstrap && bootstrap.loadFonts) { <script src="/js/i18n.js?v=20260731"></script>
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); <script src="/js/help.js?v=20260806b"></script>
}
</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> <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 chrome + i18n normalmente. ZERO modifiche backend.
// della guida.html originale: ZERO modifiche backend. if (typeof loadSidebar === 'function') loadSidebar(); // = loadTopnavV3()
if (typeof loadSidebar === 'function') loadSidebar();
if (typeof I18n !== 'undefined' && I18n.init) I18n.init(); if (typeof I18n !== 'undefined' && I18n.init) I18n.init();
if (typeof HelpSystem !== 'undefined' && HelpSystem.init) HelpSystem.init(); if (typeof HelpSystem !== 'undefined' && HelpSystem.init) HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
// ── Accordion self-contained (sostituisce bootstrap.Collapse, ZERO CDN) ──
function openChapter(num, doScroll) {
var body = document.getElementById('body-' + num);
var btn = document.querySelector('[data-bs-target="#body-' + num + '"]');
if (!body) return;
body.classList.add('show');
if (btn) { btn.classList.remove('collapsed'); btn.setAttribute('aria-expanded', 'true'); }
if (doScroll) {
var sec = document.getElementById('cap-' + num);
if (sec) sec.scrollIntoView({ behavior: 'smooth' });
}
}
document.querySelectorAll('.accordion-button[data-bs-target]').forEach(function (btn) {
btn.addEventListener('click', function () {
var sel = btn.getAttribute('data-bs-target'); // es. #body-5
var body = document.querySelector(sel);
if (!body) return;
var willOpen = !body.classList.contains('show');
body.classList.toggle('show', willOpen);
btn.classList.toggle('collapsed', !willOpen);
btn.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
});
});
// Apri automaticamente il capitolo se si arriva con un'ancora (#cap-N). // Apri automaticamente il capitolo se si arriva con un'ancora (#cap-N).
(function openFromHash() { (function openFromHash() {
var h = window.location.hash; var h = window.location.hash;
if (!h) return; if (!h) return;
var num = h.replace('#cap-', ''); openChapter(h.replace('#cap-', ''), true);
var body = document.getElementById('body-' + num);
if (body && window.bootstrap && bootstrap.Collapse) {
bootstrap.Collapse.getOrCreateInstance(body).show();
document.getElementById('cap-' + num).scrollIntoView({ behavior: 'smooth' });
}
})(); })();
window.addEventListener('hashchange', function () { window.addEventListener('hashchange', function () {
var num = window.location.hash.replace('#cap-', ''); openChapter(window.location.hash.replace('#cap-', ''), false);
var body = document.getElementById('body-' + num);
if (body && window.bootstrap && bootstrap.Collapse) {
bootstrap.Collapse.getOrCreateInstance(body).show();
}
}); });
</script> </script>
</body> </body>
+33 -32
View File
@@ -3,12 +3,12 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gestione Incidenti - NIS2 Agile</title> <title>Incidenti · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css (self-host only, nessun CDN esterno). -->
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
via JS (tabelle/decision-tree/modali) -> parita' funzionale assoluta. BI veste la chrome. --> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <!-- style.css resta caricato DOPO v3.css: veste i widget renderizzati via JS
<link rel="stylesheet" href="/css/style.css?v=20260618"> (tabelle/decision-tree/timeline/modali/badge). Self-hostato (nessun CDN). v3.css governa chrome + shell. -->
<style> <style>
/* ── Filters Bar ─────────────────────────────────────── */ /* ── Filters Bar ─────────────────────────────────────── */
.filters-bar { .filters-bar {
@@ -274,16 +274,23 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content"> <main class="wrap">
<header class="content-header"> <nav class="crumb" aria-label="breadcrumb">
<h1 class="h2" data-i18n="incidents.title">Gestione Incidenti</h1> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<div class="content-header-actions"> <i data-lucide="chevron-right" class="ic"></i><span data-i18n="incidents.title">Gestione Incidenti</span>
<button class="btn btn-primary" onclick="showCreateIncidentModal()">+ Nuovo Incidente</button> </nav>
</div> <div class="page-head">
</header> <div>
<h1 data-i18n="incidents.title">Gestione Incidenti</h1>
<p class="sub">Registro incidenti e notifiche CSIRT · Art. 23 NIS2 (24h / 72h / 30g)</p>
</div>
<div class="content-header-actions">
<button class="btn-primary" onclick="showCreateIncidentModal()"><i data-lucide="plus" class="ic"></i>Nuovo Incidente</button>
</div>
</div>
<div class="content-body"> <div class="content-body">
<!-- List View --> <!-- List View -->
@@ -348,29 +355,23 @@
<!-- Detail View --> <!-- Detail View -->
<div id="view-detail" class="hidden"></div> <div id="view-detail" class="hidden"></div>
</div> </div>
</main> </main>
</div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="/js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → pagina -->
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <script src="/js/api.js?v=20260730a"></script>
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, <script src="/js/common.js?v=20260729b"></script>
riusando tutti gli helper di common.js (zero backend). --> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script> <script src="/js/i18n.js?v=20260731"></script>
if (window.bootstrap && bootstrap.loadFonts) { <script src="/js/help.js?v=20260806b"></script>
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</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> <script>
// ── Auth & Init ────────────────────────────────────────────── // ── Auth & Init ──────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
I18n.init(); I18n.init();
HelpSystem.init(); HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
// ── Labels ─────────────────────────────────────────────────── // ── Labels ───────────────────────────────────────────────────
const CLASSIFICATION_LABELS = { const CLASSIFICATION_LABELS = {
+32
View File
@@ -91,6 +91,7 @@ $controllerMap = [
'raci' => 'RaciController', // A4 Fase 4.3 — Matrice RACI + link m2m (procedure/inventario/rischi/misure) 'raci' => 'RaciController', // A4 Fase 4.3 — Matrice RACI + link m2m (procedure/inventario/rischi/misure)
'review-schedule' => 'ReviewScheduleController', // A4 Fase 4.4 — Scadenziario centralizzato (revisioni periodiche GV.PO-02/GV.SC-07/PR.AT/DE.CM) '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)
'consultant' => 'ConsultantController', // Cruscotto Studio — vista d'insieme aziende gestite + scadenze aggregate
'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) 'stakeholder-activities' => 'StakeholderActivityController', // Epic C / C5.2 — Attività stakeholder (questionari/azioni + calendario)
'stakeholder-portal' => 'StakeholderPortalController', // Epic C / C5.2b — Portale esterno stakeholder (token magic-link, NO JWT) 'stakeholder-portal' => 'StakeholderPortalController', // Epic C / C5.2b — Portale esterno stakeholder (token magic-link, NO JWT)
@@ -114,6 +115,8 @@ $controllerMap = [
'services' => 'ServicesController', 'services' => 'ServicesController',
'invites' => 'InviteController', 'invites' => 'InviteController',
'webhooks' => 'WebhookController', 'webhooks' => 'WebhookController',
'discovery-connectors' => 'DiscoveryConnectorController', // Connettori discovery rete/cloud → auto-popola Inventario
'institutional-docs' => 'InstitutionalDocsController', // Documenti istituzionali per-org (dashboard) — ticket #499
'whistleblowing'=> 'WhistleblowingController', 'whistleblowing'=> 'WhistleblowingController',
'normative' => 'NormativeController', 'normative' => 'NormativeController',
'cross-analysis' => 'CrossAnalysisController', 'cross-analysis' => 'CrossAnalysisController',
@@ -264,6 +267,11 @@ $actionMap = [
'POST:state' => 'setState', // upsert valutazione/stato per requisito (org-scoped) 'POST:state' => 'setState', // upsert valutazione/stato per requisito (org-scoped)
], ],
// ── ConsultantController — Cruscotto Studio (vista d'insieme aziende gestite) ──
'consultant' => [
'GET:portfolio' => 'portfolio',
],
// ── AssessmentController ──────────────────────── // ── AssessmentController ────────────────────────
'assessments' => [ 'assessments' => [
'GET:list' => 'list', 'GET:list' => 'list',
@@ -322,6 +330,7 @@ $actionMap = [
// ── RiskController ────────────────────────────── // ── RiskController ──────────────────────────────
'risks' => [ 'risks' => [
'GET:list' => 'list', 'GET:list' => 'list',
'GET:derived' => 'derivedList', // #500 fase 2: rischi derivati dai requisiti LEX&ISO
'POST:create' => 'create', 'POST:create' => 'create',
'GET:{id}' => 'get', 'GET:{id}' => 'get',
'PUT:{id}' => 'update', 'PUT:{id}' => 'update',
@@ -335,6 +344,8 @@ $actionMap = [
'GET:kri' => 'listKri', // P2: dashboard KRI 'GET:kri' => 'listKri', // P2: dashboard KRI
'POST:kri' => 'createKri', 'POST:kri' => 'createKri',
'PUT:kri/{subId}' => 'updateKri', 'PUT:kri/{subId}' => 'updateKri',
'GET:requisitoActions' => 'requisitoActionsList', // #500/Modulo Azioni: azioni sul rischio-da-requisito
'POST:requisitoActions' => 'requisitoActionsCreate',
], ],
// ── IncidentController ────────────────────────── // ── IncidentController ──────────────────────────
@@ -442,6 +453,8 @@ $actionMap = [
'GET:dependencyMap' => 'dependencyMap', 'GET:dependencyMap' => 'dependencyMap',
'GET:subclasses' => 'subclasses', // Epic C / C4 — voci + sottoclassi 'GET:subclasses' => 'subclasses', // Epic C / C4 — voci + sottoclassi
'POST:subclasses' => 'addSubclass', // Epic C / C4 — aggiunta sottoclasse org 'POST:subclasses' => 'addSubclass', // Epic C / C4 — aggiunta sottoclasse org
'PUT:subclasses/{subId}' => 'updateSubclass', // C4 — rinomina sottoclasse org (#426)
'DELETE:subclasses/{subId}' => 'deleteSubclass', // C4 — rimuove sottoclasse org (#426)
'GET:{id}' => 'get', 'GET:{id}' => 'get',
'PUT:{id}' => 'update', 'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete', 'DELETE:{id}' => 'delete',
@@ -644,6 +657,7 @@ $actionMap = [
'POST:{id}/assign' => 'assign', 'POST:{id}/assign' => 'assign',
'POST:{id}/close' => 'close', 'POST:{id}/close' => 'close',
'GET:stats' => 'stats', 'GET:stats' => 'stats',
'GET:channelStatus' => 'channelStatus',
'GET:trackAnonymous' => 'trackAnonymous', 'GET:trackAnonymous' => 'trackAnonymous',
], ],
@@ -695,6 +709,24 @@ $actionMap = [
'ai' => [ 'ai' => [
'POST:ask' => 'ask', 'POST:ask' => 'ask',
'POST:tts' => 'tts', // voce naturale ARIA (proxy nexus-voice-ms)
],
// ── DiscoveryConnectorController — connettori mappatura rete/cloud (org_admin) ──
'discovery-connectors' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/rotateKey' => 'rotateKey',
],
// ── InstitutionalDocsController — Documenti istituzionali dashboard (ticket #499) ──
'institutional-docs' => [
'GET:list' => 'list',
'POST:save' => 'save',
'DELETE:{id}' => 'delete',
], ],
// ── BrandingController — White-label firm (Fase 5 / G16) ── // ── BrandingController — White-label firm (Fase 5 / G16) ──
+56 -27
View File
@@ -3,11 +3,17 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>NIS2 Agile — Integrazioni Esterne</title> <title>Integrazioni Esterne · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN), PRIMA di style.css cosi' il design system esistente resta autorevole --> <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="css/style.css?v=20260618"> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<style> <style>
/* ── V3 compat bridge: token usati dalla page-CSS qui sotto (palette CHIARA V3) ── */
:root{
--primary:#1F5FA8;
--text-primary:var(--ink); --text-secondary:var(--muted);
--bg-card:#fff; --border:var(--line);
}
/* ── Page-specific overrides ── */ /* ── Page-specific overrides ── */
.integ-hero { .integ-hero {
background: linear-gradient(135deg, rgba(6,182,212,.08) 0%, rgba(99,102,241,.06) 100%); background: linear-gradient(135deg, rgba(6,182,212,.08) 0%, rgba(99,102,241,.06) 100%);
@@ -55,7 +61,7 @@
.api-table { width: 100%; border-collapse: collapse; font-size: .83rem; margin-bottom: 1.5rem; } .api-table { width: 100%; border-collapse: collapse; font-size: .83rem; margin-bottom: 1.5rem; }
.api-table th { .api-table th {
background: rgba(255,255,255,.04); background: #F5F8FC;
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 600; text-align: left; font-weight: 600; text-align: left;
padding: .6rem .75rem; padding: .6rem .75rem;
@@ -63,10 +69,14 @@
} }
.api-table td { .api-table td {
padding: .55rem .75rem; padding: .55rem .75rem;
border-bottom: 1px solid rgba(255,255,255,.04); border-bottom: 1px solid var(--line);
vertical-align: top; vertical-align: top;
} }
.api-table tr:hover td { background: rgba(255,255,255,.02); } .api-table tr:hover td { background: #F8FAFD; }
/* Badge stato (ex style.css) usati nei tab Inviti/Licenze */
.badge-status{display:inline-block;font-size:.7rem;font-weight:700;padding:.15rem .5rem;border-radius:6px;background:#EEF2F7;color:#5b6b80}
.badge-status.s-pending{background:#FBF1E2;color:#9A6418}
.badge-status.s-used{background:#E6F4EC;color:#1E7A4E}
.api-table code { .api-table code {
background: rgba(6,182,212,.1); background: rgba(6,182,212,.1);
color: var(--primary); color: var(--primary);
@@ -123,7 +133,7 @@
counter-increment: steps; counter-increment: steps;
display: flex; align-items: flex-start; gap: .75rem; display: flex; align-items: flex-start; gap: .75rem;
padding: .65rem 0; padding: .65rem 0;
border-bottom: 1px solid rgba(255,255,255,.04); border-bottom: 1px solid var(--line);
font-size: .85rem; font-size: .85rem;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.5; line-height: 1.5;
@@ -169,7 +179,7 @@
.event-list { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1.5rem; } .event-list { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1.5rem; }
.event-row { .event-row {
display: flex; align-items: center; gap: .75rem; display: flex; align-items: center; gap: .75rem;
background: rgba(255,255,255,.03); background: #F8FAFD;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
padding: .6rem .9rem; padding: .6rem .9rem;
@@ -221,12 +231,19 @@
</head> </head>
<body> <body>
<div id="sidebar-placeholder"></div> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="main-content"> <main class="wrap">
<div class="page-header"> <nav class="crumb" aria-label="breadcrumb">
<h1>Integrazioni Esterne</h1> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<p>API, webhook e connettori per sistemi GRC di terze parti</p> <i data-lucide="chevron-right" class="ic"></i><span>Integrazioni Esterne</span>
</nav>
<div class="page-head">
<div>
<h1>Integrazioni Esterne</h1>
<p class="sub">API, webhook e connettori per sistemi GRC di terze parti</p>
</div>
</div> </div>
<!-- Hero --> <!-- Hero -->
@@ -912,20 +929,32 @@ curl -H <span class="str">"X-API-Key: nis2_TUA_CHIAVE"</span> \
</main> </main>
<script src="js/common.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="js/i18n.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help -->
<script src="/js/api.js?v=20260730a"></script>
<script src="/js/common.js?v=20260729b"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
function showTab(id) { if (!checkAuth()) throw new Error('Not authenticated');
document.querySelectorAll('.tab-btn').forEach((b, i) => { loadSidebar(); // = loadTopnavV3()
const ids = ['api', 'invites', 'lg231', 'webhook', 'quickstart']; if (typeof I18n !== 'undefined') I18n.init();
b.classList.toggle('active', ids[i] === id); if (typeof HelpSystem !== 'undefined') HelpSystem.init();
}); if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
document.querySelectorAll('.tab-pane').forEach(p => {
p.classList.toggle('active', p.id === 'tab-' + id); function showTab(id) {
}); document.querySelectorAll('.tab-btn').forEach((b, i) => {
// scroll to tabs const ids = ['api', 'invites', 'lg231', 'webhook', 'quickstart'];
document.querySelector('.tab-nav').scrollIntoView({ behavior: 'smooth', block: 'start' }); b.classList.toggle('active', ids[i] === id);
} });
document.querySelectorAll('.tab-pane').forEach(p => {
p.classList.toggle('active', p.id === 'tab-' + id);
});
// scroll to tabs
document.querySelector('.tab-nav').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
</script> </script>
</body> </body>
</html> </html>
+43 -21
View File
@@ -3,10 +3,21 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Audit interni - NIS2 Agile</title> <title>Audit interni · NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia CDN). -->
<link rel="stylesheet" href="/css/style.css?v=20260618"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style> <style>
/* ── Compat V3: token + bottoni emessi dal JS (ex style.css, niente CDN) ── */
:root{
--primary:#1F5FA8;--gray-100:#F1F5F9;--gray-200:#E3E9F0;--gray-400:#94A3B8;--gray-500:#64748B;
}
.btn{display:inline-flex;align-items:center;gap:7px;border:1px solid transparent;border-radius:8px;padding:8px 13px;font:inherit;font-weight:600;font-size:12.5px;cursor:pointer;line-height:1.2}
.btn:disabled{opacity:.55;cursor:not-allowed}
.btn-primary{background:var(--c-blue);color:#fff}.btn-primary:hover:not(:disabled){background:#1A5494}
.btn-outline{background:#fff;color:var(--ink);border-color:var(--line)}.btn-outline:hover:not(:disabled){background:#F5F8FC;border-color:#c7d4e4}
.btn-sm{padding:5px 11px;font-size:11.5px}
a.btn{text-decoration:none}
.ia-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; } .ia-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
.ia-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; } .ia-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
.ia-toolbar { display:flex; justify-content:flex-end; margin-bottom:12px; } .ia-toolbar { display:flex; justify-content:flex-end; margin-bottom:12px; }
@@ -68,13 +79,23 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content">
<header class="content-header"> <main class="wrap">
<h2 data-i18n="iaud.title">Audit interni</h2> <nav class="crumb" aria-label="breadcrumb">
</header> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<div class="content-body"> <i data-lucide="chevron-right" class="ic"></i><span>Monitoraggio</span>
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="iaud.title">Audit interni</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="iaud.title">Audit interni</h1>
<p class="sub">Programma, checklist ed esiti degli audit interni del SGSI (ISO/IEC 27001 §9.2)</p>
</div>
<button class="btn-primary" onclick="auditOpen()"><i data-lucide="plus" class="ic"></i>Nuovo audit</button>
</div>
<section class="card card-pad">
<div class="ia-intro"> <div class="ia-intro">
<strong>Audit interni del SGSI (ISO/IEC 27001 §9.2).</strong> <strong>Audit interni del SGSI (ISO/IEC 27001 §9.2).</strong>
Programma gli audit, conduci la <em>checklist</em> (clausole 4-10 ISO 27001 + controlli Annex A applicabili dal tuo SoA), Programma gli audit, conduci la <em>checklist</em> (clausole 4-10 ISO 27001 + controlli Annex A applicabili dal tuo SoA),
@@ -85,11 +106,9 @@
(<strong>GV.PO-02</strong>, monitoraggio del programma di sicurezza). Strumento di supporto organizzativo, non un parere legale. (<strong>GV.PO-02</strong>, monitoraggio del programma di sicurezza). Strumento di supporto organizzativo, non un parere legale.
</div> </div>
<div class="ia-toolbar"><button class="btn btn-primary btn-sm" onclick="auditOpen()">+ Nuovo audit</button></div>
<div id="aud-list" aria-live="polite"></div> <div id="aud-list" aria-live="polite"></div>
</div> </section>
</main> </main>
</div>
<!-- Modale crea/modifica audit --> <!-- Modale crea/modifica audit -->
<div class="sm-overlay" id="aud-modal" role="dialog" aria-modal="true" aria-labelledby="aud-modal-title"> <div class="sm-overlay" id="aud-modal" role="dialog" aria-modal="true" aria-labelledby="aud-modal-title">
@@ -139,12 +158,14 @@
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="/js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → init pagina -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/api.js?v=20260730a"></script>
<script src="/js/common-bi.js?v=20260631"></script> <script src="/js/common.js?v=20260729b"></script>
<script src="/js/i18n.js?v=20260631"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/help.js?v=20260618p2"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
'use strict'; 'use strict';
/* /*
@@ -172,9 +193,10 @@
document.addEventListener('DOMContentLoaded', async function () { document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return; if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar(); if (typeof loadSidebar === 'function') loadSidebar();
if (window.I18n && I18n.init) I18n.init('it');
if (window.HelpSystem && HelpSystem.init) HelpSystem.init(); if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
document.addEventListener('keydown', e => { if (e.key === 'Escape') { auditClose(); detClose(); } }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { auditClose(); detClose(); } });
['aud-modal', 'det-modal'].forEach(m => { const o = el(m); if (o) o.addEventListener('click', e => { if (e.target === o) o.classList.remove('open'); }); }); ['aud-modal', 'det-modal'].forEach(m => { const o = el(m); if (o) o.addEventListener('click', e => { if (e.target === o) o.classList.remove('open'); }); });
await iaLoadAll(); await iaLoadAll();
+174 -160
View File
@@ -3,49 +3,54 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Modello Organizzativo SGSI (ISO 27001) - NIS2 Agile</title> <title>Modello Organizzativo SGSI (ISO 27001) · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css. Self-host only, nessun CDN esterno. -->
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
via JS (wizard/SoA/tabelle) -> parita' funzionale assoluta. BI veste la chrome. --> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<style> <style>
.isms-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:20px; font-size:.92rem; line-height:1.6; } /* Stili specifici della pagina SGSI non coperti da v3.css (nessun CDN). */
.isms-steps { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:20px; } .text-muted { color: var(--muted); }
.isms-step { flex:1 1 140px; min-width:120px; padding:10px 12px; border:1.5px solid var(--gray-200,#e5e7eb); border-radius:10px; background:#fff; cursor:pointer; font-size:.82rem; text-align:center; } .sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }
.isms-step.active { border-color:var(--primary,#2563eb); background:#eff6ff; font-weight:700; } /* Base .btn usata dai bottoni generati via JS (es. "btn btn-sm btn-outline") */
.isms-step.done { border-color:#16a34a; } .btn { display:inline-flex; align-items:center; justify-content:center; gap:7px; font:inherit; font-weight:600; font-size:12.5px; border-radius:8px; padding:8px 13px; border:1px solid transparent; cursor:pointer; line-height:1.4; text-decoration:none; }
.isms-step .num { display:inline-block; width:22px; height:22px; line-height:22px; border-radius:50%; background:var(--gray-100,#f3f4f6); font-weight:700; margin-bottom:4px; } .btn .icon, .btn .ic { width:15px; height:15px; }
.isms-step.done .num { background:#16a34a; color:#fff; } .isms-intro { background:#EAF1FA; border-left:4px solid var(--c-blue); padding:14px 18px; border-radius:10px; margin-bottom:18px; font-size:.92rem; line-height:1.6; color:var(--ink); }
.isms-steps { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:20px; }
.isms-step { flex:1 1 140px; min-width:120px; padding:11px 12px; border:1.5px solid var(--line); border-radius:11px; background:#fff; cursor:pointer; font-size:.82rem; text-align:center; transition:border-color .12s,background .12s; }
.isms-step:hover { border-color:#9FBEDD; }
.isms-step.active { border-color:var(--c-blue); background:#EAF1FA; font-weight:700; }
.isms-step.done { border-color:var(--c-green); }
.isms-step .num { display:inline-block; width:22px; height:22px; line-height:22px; border-radius:50%; background:#EEF2F7; font-weight:700; margin-bottom:4px; }
.isms-step.done .num { background:var(--c-green); color:#fff; }
.isms-panel { display:none; } .isms-panel { display:none; }
.isms-panel.active { display:block; } .isms-panel.active { display:block; }
.isms-disclaimer { font-size:.8rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:12px 0; } .isms-panel h3 { margin:0 0 12px; font-size:17px; font-weight:800; }
.raci-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; padding:8px 0; border-bottom:1px solid var(--gray-100,#f3f4f6); } .isms-panel h4 { margin:16px 0 8px; font-size:14px; font-weight:700; }
.raci-row input, .raci-row select { padding:6px 8px; border:1px solid var(--gray-200,#e5e7eb); border-radius:6px; font-size:.85rem; } .isms-disclaimer { font-size:.8rem; color:#92400e; background:#FBF1E2; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:12px 0; }
.soa-std { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; margin-bottom:14px; overflow:hidden; } .raci-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; padding:8px 0; border-bottom:1px solid var(--line); }
.soa-std-head { background:var(--gray-50,#f9fafb); padding:12px 16px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; } .raci-row input, .raci-row select { padding:8px 10px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:.85rem; color:var(--ink); }
.soa-std { border:1px solid var(--line); border-radius:11px; margin-bottom:14px; overflow:hidden; }
.soa-std-head { background:#F5F8FC; padding:12px 16px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; }
.soa-std-body { padding:6px 14px 14px; display:none; } .soa-std-body { padding:6px 14px 14px; display:none; }
.soa-std.open .soa-std-body { display:block; } .soa-std.open .soa-std-body { display:block; }
.soa-theme { font-size:.78rem; font-weight:700; text-transform:uppercase; color:var(--gray-500,#6b7280); margin:14px 0 6px; letter-spacing:.04em; } .soa-theme { font-size:.78rem; font-weight:700; text-transform:uppercase; color:var(--muted); margin:14px 0 6px; letter-spacing:.04em; }
.soa-ctrl { padding:12px 0; border-top:1px solid var(--gray-100,#f3f4f6); } .soa-ctrl { padding:12px 0; border-top:1px solid var(--line); }
.soa-ctrl-head { display:flex; gap:10px; align-items:flex-start; justify-content:space-between; flex-wrap:wrap; } .soa-ctrl-head { display:flex; gap:10px; align-items:flex-start; justify-content:space-between; flex-wrap:wrap; }
.soa-code { display:inline-block; background:#eef2ff; color:#3730a3; font-weight:700; font-size:.72rem; padding:2px 8px; border-radius:6px; margin-right:6px; } .soa-code { display:inline-block; background:#E7F0FB; color:#1F5FA8; font-weight:700; font-size:.72rem; padding:2px 8px; border-radius:6px; margin-right:6px; }
.soa-derived { background:#ecfdf5; color:#065f46; font-size:.68rem; padding:1px 6px; border-radius:5px; } .soa-derived { background:#E6F4EC; color:#1E7A4E; font-size:.68rem; padding:1px 6px; border-radius:5px; }
.soa-opts { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; } .soa-opts { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.soa-opt { min-height:34px; padding:5px 10px; border:1.5px solid var(--gray-200,#e5e7eb); border-radius:18px; background:#fff; cursor:pointer; font-size:.8rem; font-weight:600; } .soa-opt { min-height:34px; padding:5px 10px; border:1.5px solid var(--line); border-radius:18px; background:#fff; cursor:pointer; font:inherit; font-size:.8rem; font-weight:600; color:var(--ink); }
.soa-opt.sel-implemented,.soa-opt.sel-verified { background:#16a34a; color:#fff; border-color:#16a34a; } .soa-opt.sel-implemented,.soa-opt.sel-verified { background:var(--c-green); color:#fff; border-color:var(--c-green); }
.soa-opt.sel-in_progress { background:#f59e0b; color:#fff; border-color:#f59e0b; } .soa-opt.sel-in_progress { background:var(--c-amber); color:#fff; border-color:var(--c-amber); }
.soa-opt.sel-not_started { background:#9ca3af; color:#fff; border-color:#9ca3af; } .soa-opt.sel-not_started { background:#9ca3af; color:#fff; border-color:#9ca3af; }
.soa-justif { width:100%; margin-top:8px; padding:8px; border:1px solid var(--gray-200,#e5e7eb); border-radius:6px; font-size:.84rem; min-height:46px; } .soa-justif { width:100%; margin-top:8px; padding:8px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:.84rem; min-height:46px; color:var(--ink); }
.isms-field { margin-bottom:14px; } .isms-field { margin-bottom:14px; }
.isms-field label { display:block; font-weight:600; font-size:.88rem; margin-bottom:4px; } .isms-field label { display:block; font-weight:600; font-size:.88rem; margin-bottom:4px; }
.isms-field textarea, .isms-field input { width:100%; padding:9px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; } .isms-field textarea, .isms-field input { width:100%; padding:9px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:.9rem; color:var(--ink); }
.isms-flags label { display:block; padding:8px 0; font-size:.9rem; } .isms-flags label { display:block; padding:8px 0; font-size:.9rem; }
.ck-item { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--gray-100,#f3f4f6); font-size:.9rem; } .ck-item { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--line); font-size:.9rem; }
.ck-yes { color:#16a34a; font-weight:700; } .ck-no { color:#9ca3af; } .ck-yes { color:var(--c-green); font-weight:700; } .ck-no { color:#9ca3af; }
.savehint { font-size:.78rem; color:var(--gray-400,#9ca3af); min-height:16px; } .savehint { font-size:.78rem; color:var(--muted); min-height:16px; }
/* Allinea l'icona sprite BI nel bottone Esporta col testo */
.content-header-actions .btn .icon { vertical-align:text-bottom; margin-right:4px; }
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -62,142 +67,151 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="isms.title">Modello Organizzativo SGSI (ISO 27001)</h2>
<div class="content-header-actions">
<span class="savehint" id="isms-savehint" role="status" aria-live="polite"></span>
<button class="btn btn-outline btn-sm" id="nis2-isms-btn-export" onclick="ismsExportView()" data-i18n="isms.export">
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-file"></use></svg>
<span>Esporta SGSI</span>
</button>
</div>
</header>
<div class="content-body"> <main class="wrap">
<div class="isms-intro" id="nis2-isms-intro"> <nav class="crumb" aria-label="breadcrumb">
<strong data-i18n="isms.intro.title">Costruisci il tuo Sistema di Gestione della Sicurezza delle Informazioni.</strong> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<span data-i18n="isms.intro.body">Procedura guidata ISO/IEC 27001:2022 (clausole 4-10 + Statement of Applicability). Lo Statement of Applicability viene pre-popolato dalle tue risposte alla Gap Analysis NIS2. Attiva le estensioni cloud ISO 27017/27018 dove pertinenti.</span> <i data-lucide="chevron-right" class="ic"></i><span>SGSI (ISO 27001)</span>
</div> </nav>
<div class="isms-disclaimer" id="nis2-isms-disclaimer" data-i18n="isms.disclaimer">Strumento di supporto e pre-audit: non costituisce una certificazione ISO 27001 né un parere professionale vincolante. Gli obblighi normativi in Italia derivano da NIS2 / D.Lgs. 138/2024 / Determinazioni ACN; ISO 27001/27017/27018 sono best practice.</div> <div class="page-head">
<div>
<h1 data-i18n="isms.title">Modello Organizzativo SGSI (ISO 27001)</h1>
<p class="sub">Procedura guidata ISO/IEC 27001:2022 — clausole 4–10 + Statement of Applicability</p>
</div>
<div class="content-header-actions" style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<span class="savehint" id="isms-savehint" role="status" aria-live="polite"></span>
<button class="btn-ghost" id="nis2-isms-btn-export" onclick="ismsExportView()" data-i18n="isms.export">
<i data-lucide="file-text" class="ic"></i>
<span>Esporta SGSI</span>
</button>
</div>
</div>
<div class="isms-steps" id="isms-steps"></div> <section class="card card-pad">
<div class="isms-intro" id="nis2-isms-intro">
<strong data-i18n="isms.intro.title">Costruisci il tuo Sistema di Gestione della Sicurezza delle Informazioni.</strong>
<span data-i18n="isms.intro.body">Procedura guidata ISO/IEC 27001:2022 (clausole 4-10 + Statement of Applicability). Lo Statement of Applicability viene pre-popolato dalle tue risposte alla Gap Analysis NIS2. Attiva le estensioni cloud ISO 27017/27018 dove pertinenti.</span>
</div>
<div class="isms-disclaimer" id="nis2-isms-disclaimer" data-i18n="isms.disclaimer">Strumento di supporto e pre-audit: non costituisce una certificazione ISO 27001 né un parere professionale vincolante. Gli obblighi normativi in Italia derivano da NIS2 / D.Lgs. 138/2024 / Determinazioni ACN; ISO 27001/27017/27018 sono best practice.</div>
<!-- STEP 1 - Contesto & Ambito (cl.4) --> <div class="isms-steps" id="isms-steps"></div>
<div class="isms-panel" id="panel-0">
<div class="card"><div class="card-body">
<h3 data-i18n="isms.s1.title">1. Contesto e Ambito (cl. 4)</h3>
<div class="isms-field"><label for="f-scope" data-i18n="isms.s1.scope">Ambito del SGSI (scope statement)</label><textarea id="f-scope" rows="3" placeholder="Es. Tutti i sistemi e processi a supporto dei servizi ICT erogati dalla sede di..."></textarea></div>
<div class="isms-field"><label for="f-ctx-int" data-i18n="isms.s1.ctx_int">Contesto interno</label><textarea id="f-ctx-int" rows="2"></textarea></div>
<div class="isms-field"><label for="f-ctx-ext" data-i18n="isms.s1.ctx_ext">Contesto esterno</label><textarea id="f-ctx-ext" rows="2"></textarea></div>
<div class="isms-field"><label for="f-parties" data-i18n="isms.s1.parties">Parti interessate (una per riga)</label><textarea id="f-parties" rows="3" placeholder="Clienti&#10;Autorità (ACN/CSIRT)&#10;Fornitori ICT"></textarea></div>
<div class="isms-field"><label for="f-boundaries" data-i18n="isms.s1.boundaries">Confini del SGSI</label><textarea id="f-boundaries" rows="2"></textarea></div>
<div class="isms-field"><label for="f-exclusions" data-i18n="isms.s1.exclusions">Esclusioni motivate</label><textarea id="f-exclusions" rows="2"></textarea></div>
<div class="isms-flags">
<strong data-i18n="isms.s1.cloud">Estensioni cloud (attivano i controlli condizionali nel SoA)</strong>
<label><input type="checkbox" id="f-uses-cloud"> <span data-i18n="isms.s1.uses_cloud">Usiamo servizi cloud pubblici (Microsoft 365, AWS, Google...) → ISO 27017</span></label>
<label><input type="checkbox" id="f-cloud-provider"> <span data-i18n="isms.s1.cloud_provider">Siamo fornitori di servizi cloud → ISO 27017 (lato provider)</span></label>
<label><input type="checkbox" id="f-pii-cloud"> <span data-i18n="isms.s1.pii_cloud">Trattiamo dati personali (PII) in cloud pubblico come responsabili → ISO 27018</span></label>
</div>
<button class="btn btn-primary" id="nis2-isms-btn-save-step1" onclick="saveStep1()" data-i18n="isms.save_next">Salva e continua</button>
</div></div>
</div>
<!-- STEP 2 - Leadership (cl.5) --> <!-- STEP 1 - Contesto & Ambito (cl.4) -->
<div class="isms-panel" id="panel-1"> <div class="isms-panel" id="panel-0">
<div class="card"><div class="card-body"> <div class="card card-pad">
<h3 data-i18n="isms.s2.title">2. Leadership: ruoli e responsabilità (cl. 5)</h3> <h3 data-i18n="isms.s1.title">1. Contesto e Ambito (cl. 4)</h3>
<p class="text-muted" data-i18n="isms.s2.hint">Definisci i ruoli del SGSI con la matrice RACI (Responsible, Accountable, Consulted, Informed).</p> <div class="isms-field"><label for="f-scope" data-i18n="isms.s1.scope">Ambito del SGSI (scope statement)</label><textarea id="f-scope" rows="3" placeholder="Es. Tutti i sistemi e processi a supporto dei servizi ICT erogati dalla sede di..."></textarea></div>
<div id="roles-list"></div> <div class="isms-field"><label for="f-ctx-int" data-i18n="isms.s1.ctx_int">Contesto interno</label><textarea id="f-ctx-int" rows="2"></textarea></div>
<div class="raci-row" style="border-top:2px solid var(--gray-200,#e5e7eb); margin-top:10px;"> <div class="isms-field"><label for="f-ctx-ext" data-i18n="isms.s1.ctx_ext">Contesto esterno</label><textarea id="f-ctx-ext" rows="2"></textarea></div>
<input id="r-name" placeholder="Ruolo (es. CISO)" aria-label="Ruolo" style="flex:1 1 160px;"> <div class="isms-field"><label for="f-parties" data-i18n="isms.s1.parties">Parti interessate (una per riga)</label><textarea id="f-parties" rows="3" placeholder="Clienti&#10;Autorità (ACN/CSIRT)&#10;Fornitori ICT"></textarea></div>
<input id="r-resp" placeholder="Responsabilità" aria-label="Responsabilità" style="flex:2 1 220px;"> <div class="isms-field"><label for="f-boundaries" data-i18n="isms.s1.boundaries">Confini del SGSI</label><textarea id="f-boundaries" rows="2"></textarea></div>
<select id="r-raci" aria-label="RACI"><option value="">RACI</option><option>R</option><option>A</option><option>C</option><option>I</option></select> <div class="isms-field"><label for="f-exclusions" data-i18n="isms.s1.exclusions">Esclusioni motivate</label><textarea id="f-exclusions" rows="2"></textarea></div>
<button class="btn btn-sm btn-primary" id="nis2-isms-btn-add-role" onclick="addRole()" data-i18n="isms.s2.add">Aggiungi ruolo</button> <div class="isms-flags">
</div> <strong data-i18n="isms.s1.cloud">Estensioni cloud (attivano i controlli condizionali nel SoA)</strong>
<button class="btn btn-outline" id="nis2-isms-btn-next-step2" style="margin-top:14px;" onclick="goStep(2)" data-i18n="isms.next">Continua</button> <label><input type="checkbox" id="f-uses-cloud"> <span data-i18n="isms.s1.uses_cloud">Usiamo servizi cloud pubblici (Microsoft 365, AWS, Google...) → ISO 27017</span></label>
</div></div> <label><input type="checkbox" id="f-cloud-provider"> <span data-i18n="isms.s1.cloud_provider">Siamo fornitori di servizi cloud → ISO 27017 (lato provider)</span></label>
</div> <label><input type="checkbox" id="f-pii-cloud"> <span data-i18n="isms.s1.pii_cloud">Trattiamo dati personali (PII) in cloud pubblico come responsabili → ISO 27018</span></label>
</div>
<!-- STEP 3 - Risk (cl.6) --> <button class="btn-primary" id="nis2-isms-btn-save-step1" onclick="saveStep1()" data-i18n="isms.save_next">Salva e continua</button>
<div class="isms-panel" id="panel-2">
<div class="card"><div class="card-body">
<h3 data-i18n="isms.s3.title">3. Risk: metodologia e obiettivi (cl. 6)</h3>
<div class="isms-field"><label for="f-method" data-i18n="isms.s3.method">Metodologia di risk assessment</label><textarea id="f-method" rows="3" placeholder="Es. ISO 27005 / NIST SP 800-30; scala probabilità×impatto 5×5 (già usata nel modulo Rischi)."></textarea></div>
<div class="isms-field"><label for="f-objectives" data-i18n="isms.s3.objectives">Obiettivi del SGSI (uno per riga)</label><textarea id="f-objectives" rows="3"></textarea></div>
<p class="text-muted" data-i18n="isms.s3.link">Il registro dei rischi vive nel modulo <a href="risks.html">Rischi</a>: questo passo ne dichiara la metodologia.</p>
<button class="btn btn-primary" id="nis2-isms-btn-save-step3" onclick="saveStep3()" data-i18n="isms.save_next">Salva e continua</button>
</div></div>
</div>
<!-- STEP 4 - SoA -->
<div class="isms-panel" id="panel-3">
<div class="card"><div class="card-body">
<h3 data-i18n="isms.s4.title">4. Statement of Applicability</h3>
<p class="text-muted" data-i18n="isms.s4.hint">I controlli sono pre-popolati dalle risposte NIS2 dove esiste corrispondenza (badge "da NIS2"). Conferma applicabilità, stato e motivazione per ciascuno.</p>
<div style="margin:10px 0;">
<button class="btn btn-sm btn-outline" id="nis2-isms-btn-derive-soa" onclick="deriveSoa()" data-i18n="isms.s4.derive">Ri-deriva dal NIS2</button>
<span id="soa-stats" class="text-muted" style="margin-left:12px; font-size:.85rem;"></span>
</div>
<div id="soa-groups"></div>
<button class="btn btn-outline" id="nis2-isms-btn-next-step4" style="margin-top:14px;" onclick="goStep(4)" data-i18n="isms.next">Continua</button>
</div></div>
</div>
<!-- STEP 5 - Documented information (cl.7-8) -->
<div class="isms-panel" id="panel-4">
<div class="card"><div class="card-body">
<h3 data-i18n="isms.s5.title">5. Documented Information (cl. 7-8)</h3>
<div class="raci-row" style="border:none;">
<label for="d-type" class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);">Tipo di documento</label>
<select id="d-type" style="flex:1 1 200px;" aria-label="Tipo di documento">
<option value="policy_sgsi">Policy SGSI</option>
<option value="dichiarazione_applicabilita">Dichiarazione di Applicabilità</option>
<option value="politica_controllo_accessi">Politica controllo accessi</option>
<option value="procedura_incident">Procedura gestione incidenti</option>
<option value="procedura_continuita">Procedura continuità operativa</option>
<option value="politica_fornitori">Politica sicurezza fornitori</option>
</select>
<button class="btn btn-sm btn-primary" onclick="aiGenDoc()" id="btn-aigen" data-i18n="isms.s5.aigen">Genera bozza con AI</button>
</div>
<div class="savehint" id="aigen-hint" role="status" aria-live="polite"></div>
<div id="docs-list" style="margin-top:14px;"></div>
<button class="btn btn-outline" id="nis2-isms-btn-next-step5" style="margin-top:14px;" onclick="goStep(5)" data-i18n="isms.next">Continua</button>
</div></div>
</div>
<!-- STEP 6 - Monitoring & Readiness (cl.9-10) -->
<div class="isms-panel" id="panel-5">
<div class="card"><div class="card-body">
<h3 data-i18n="isms.s6.title">6. Monitoraggio e Miglioramento (cl. 9-10)</h3>
<p class="text-muted" data-i18n="isms.s6.hint">Gli audit interni e le non conformità si gestiscono nei moduli esistenti: <a href="reports.html">Audit & Report</a> e NCR/CAPA.</p>
<h4 data-i18n="isms.s6.readiness">Completamento del SGSI</h4>
<div style="text-align:center; margin:14px 0;">
<div style="font-size:2.6rem; font-weight:800;" id="readiness-pct">0%</div>
</div>
<div id="readiness-checklist"></div>
</div></div>
</div> </div>
</div> </div>
</main>
</div>
<script src="/js/api.js?v=20260631"></script> <!-- STEP 2 - Leadership (cl.5) -->
<script src="/js/common.js?v=20260631"></script> <div class="isms-panel" id="panel-1">
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <div class="card card-pad">
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, <h3 data-i18n="isms.s2.title">2. Leadership: ruoli e responsabilità (cl. 5)</h3>
riusando tutti gli helper di common.js (zero backend). --> <p class="text-muted" data-i18n="isms.s2.hint">Definisci i ruoli del SGSI con la matrice RACI (Responsible, Accountable, Consulted, Informed).</p>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <div id="roles-list"></div>
<div class="raci-row" style="border-top:2px solid var(--line); margin-top:10px;">
<input id="r-name" placeholder="Ruolo (es. CISO)" aria-label="Ruolo" style="flex:1 1 160px;">
<input id="r-resp" placeholder="Responsabilità" aria-label="Responsabilità" style="flex:2 1 220px;">
<select id="r-raci" aria-label="RACI"><option value="">RACI</option><option>R</option><option>A</option><option>C</option><option>I</option></select>
<button class="btn-primary btn-sm" id="nis2-isms-btn-add-role" onclick="addRole()" data-i18n="isms.s2.add">Aggiungi ruolo</button>
</div>
<button class="btn-ghost" id="nis2-isms-btn-next-step2" style="margin-top:14px;" onclick="goStep(2)" data-i18n="isms.next">Continua</button>
</div>
</div>
<!-- STEP 3 - Risk (cl.6) -->
<div class="isms-panel" id="panel-2">
<div class="card card-pad">
<h3 data-i18n="isms.s3.title">3. Risk: metodologia e obiettivi (cl. 6)</h3>
<div class="isms-field"><label for="f-method" data-i18n="isms.s3.method">Metodologia di risk assessment</label><textarea id="f-method" rows="3" placeholder="Es. ISO 27005 / NIST SP 800-30; scala probabilità×impatto 5×5 (già usata nel modulo Rischi)."></textarea></div>
<div class="isms-field"><label for="f-objectives" data-i18n="isms.s3.objectives">Obiettivi del SGSI (uno per riga)</label><textarea id="f-objectives" rows="3"></textarea></div>
<p class="text-muted" data-i18n="isms.s3.link">Il registro dei rischi vive nel modulo <a href="risks.html">Rischi</a>: questo passo ne dichiara la metodologia.</p>
<button class="btn-primary" id="nis2-isms-btn-save-step3" onclick="saveStep3()" data-i18n="isms.save_next">Salva e continua</button>
</div>
</div>
<!-- STEP 4 - SoA -->
<div class="isms-panel" id="panel-3">
<div class="card card-pad">
<h3 data-i18n="isms.s4.title">4. Statement of Applicability</h3>
<p class="text-muted" data-i18n="isms.s4.hint">I controlli sono pre-popolati dalle risposte NIS2 dove esiste corrispondenza (badge "da NIS2"). Conferma applicabilità, stato e motivazione per ciascuno.</p>
<div style="margin:10px 0;">
<button class="btn-ghost btn-sm" id="nis2-isms-btn-derive-soa" onclick="deriveSoa()" data-i18n="isms.s4.derive">Ri-deriva dal NIS2</button>
<span id="soa-stats" class="text-muted" style="margin-left:12px; font-size:.85rem;"></span>
</div>
<div id="soa-groups"></div>
<button class="btn-ghost" id="nis2-isms-btn-next-step4" style="margin-top:14px;" onclick="goStep(4)" data-i18n="isms.next">Continua</button>
</div>
</div>
<!-- STEP 5 - Documented information (cl.7-8) -->
<div class="isms-panel" id="panel-4">
<div class="card card-pad">
<h3 data-i18n="isms.s5.title">5. Documented Information (cl. 7-8)</h3>
<div class="raci-row" style="border:none;">
<label for="d-type" class="sr-only">Tipo di documento</label>
<select id="d-type" style="flex:1 1 200px;" aria-label="Tipo di documento">
<option value="policy_sgsi">Policy SGSI</option>
<option value="dichiarazione_applicabilita">Dichiarazione di Applicabilità</option>
<option value="politica_controllo_accessi">Politica controllo accessi</option>
<option value="procedura_incident">Procedura gestione incidenti</option>
<option value="procedura_continuita">Procedura continuità operativa</option>
<option value="politica_fornitori">Politica sicurezza fornitori</option>
</select>
<button class="btn-primary btn-sm" onclick="aiGenDoc()" id="btn-aigen" data-i18n="isms.s5.aigen">Genera bozza con AI</button>
</div>
<div class="savehint" id="aigen-hint" role="status" aria-live="polite"></div>
<div id="docs-list" style="margin-top:14px;"></div>
<button class="btn-ghost" id="nis2-isms-btn-next-step5" style="margin-top:14px;" onclick="goStep(5)" data-i18n="isms.next">Continua</button>
</div>
</div>
<!-- STEP 6 - Monitoring & Readiness (cl.9-10) -->
<div class="isms-panel" id="panel-5">
<div class="card card-pad">
<h3 data-i18n="isms.s6.title">6. Monitoraggio e Miglioramento (cl. 9-10)</h3>
<p class="text-muted" data-i18n="isms.s6.hint">Gli audit interni e le non conformità si gestiscono nei moduli esistenti: <a href="reports.html">Audit & Report</a> e NCR/CAPA.</p>
<h4 data-i18n="isms.s6.readiness">Completamento del SGSI</h4>
<div style="text-align:center; margin:14px 0;">
<div style="font-size:2.6rem; font-weight:800; color:var(--c-blue);" id="readiness-pct">0%</div>
</div>
<div id="readiness-checklist"></div>
</div>
</div>
</section>
</main>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → isms.js.
isms.js si auto-inizializza su DOMContentLoaded (checkAuth/I18n/loadSidebar/Help/render). -->
<script src="/js/api.js?v=20260730a"></script>
<script src="/js/common.js?v=20260729b"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script src="/js/isms.js?v=20260627v3"></script>
<script> <script>
if (window.bootstrap && bootstrap.loadFonts) { // Icone Lucide della chrome + crumb/page-head statici. L'init dati e il wiring
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); // (renderSteps/loadModel/goStep) restano in isms.js (suo DOMContentLoaded).
} document.addEventListener('DOMContentLoaded', function(){
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
});
</script> </script>
<script src="/js/common-bi.js?v=20260631"></script>
<script src="/js/i18n.js?v=20260631"></script>
<script src="/js/help.js?v=20260620b"></script>
<script src="/js/isms.js?v=20260620b"></script>
</body> </body>
</html> </html>
+69 -5
View File
@@ -10,6 +10,42 @@ class NIS2API {
this.token = localStorage.getItem('nis2_access_token'); this.token = localStorage.getItem('nis2_access_token');
this.refreshToken = localStorage.getItem('nis2_refresh_token'); this.refreshToken = localStorage.getItem('nis2_refresh_token');
this.orgId = localStorage.getItem('nis2_org_id'); this.orgId = localStorage.getItem('nis2_org_id');
this._refreshTimer = null;
if (this.token) this.scheduleTokenRefresh();
}
// ── Refresh proattivo/silenzioso ────────────────────────────────────
// Rinnova l'access token PRIMA che scada, così l'utente non incontra 401 né deve
// rifare il login (il refresh token dura 7 giorni). Evita la "sessione scaduta".
_tokenExp(token) {
try {
const raw = (token || this.token || '').split('.')[1];
const p = JSON.parse(atob(raw.replace(/-/g, '+').replace(/_/g, '/')));
return (typeof p.exp === 'number') ? p.exp : 0;
} catch (e) { return 0; }
}
scheduleTokenRefresh() {
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
if (!this.token || !this.refreshToken) return;
const exp = this._tokenExp(this.token);
if (!exp) return;
let delay = exp * 1000 - Date.now() - 120000; // rinnova ~2 min prima della scadenza
if (delay < 1000) delay = 1000;
if (delay > 2000000000) delay = 2000000000; // cap (limite setTimeout a 32 bit)
this._refreshTimer = setTimeout(() => {
if (this.isAuthenticated() && this.refreshToken) this.doRefreshToken().catch(() => {});
}, delay);
}
// Rinnova SUBITO se l'access è scaduto o entro 5 min (usato al ritorno sulla scheda
// dopo sospensione/inattività, quando i timer possono non essere scattati).
async ensureFreshToken() {
if (!this.token || !this.refreshToken) return;
const exp = this._tokenExp(this.token);
if (exp && exp * 1000 - Date.now() < 300000) {
await this.doRefreshToken().catch(() => {});
}
} }
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -40,11 +76,22 @@ class NIS2API {
const response = await fetch(url, config); const response = await fetch(url, config);
const json = await response.json(); const json = await response.json();
// Token expired - try refresh // Token expired - try refresh. ESCLUDI gli endpoint auth stessi: un 401 su
if (response.status === 401 && this.refreshToken && !options._isRetry) { // /auth/refresh (refresh token scaduto/invalido) NON deve ri-triggerare il
const refreshed = await this.doRefreshToken(); // refresh → altrimenti ricorsione infinita, doRefreshToken() non ritorna mai,
if (refreshed) { // logout()/redirect non scatta e l'utente resta su una pagina rotta ("--").
return this.request(method, endpoint, data, { ...options, _isRetry: true }); // (Bug utente "sono tornato dopo un po'": access scaduto + refresh non valido.)
const isAuthEndpoint = endpoint === '/auth/refresh' || endpoint === '/auth/login';
if (response.status === 401 && !isAuthEndpoint && !options._isRetry) {
if (this.refreshToken) {
const refreshed = await this.doRefreshToken();
if (refreshed) {
return this.request(method, endpoint, data, { ...options, _isRetry: true });
}
// refresh fallito → doRefreshToken() ha già fatto logout()+redirect a login
} else {
// 401 senza refresh token → sessione non recuperabile → vai a login
this.logout();
} }
} }
@@ -122,6 +169,7 @@ class NIS2API {
this.refreshToken = refresh; this.refreshToken = refresh;
localStorage.setItem('nis2_access_token', access); localStorage.setItem('nis2_access_token', access);
localStorage.setItem('nis2_refresh_token', refresh); localStorage.setItem('nis2_refresh_token', refresh);
this.scheduleTokenRefresh(); // riprogramma il refresh silenzioso sul nuovo exp
} }
clearTokens() { clearTokens() {
@@ -166,6 +214,9 @@ class NIS2API {
updateOrganization(id, data) { return this.put(`/organizations/${id}`, data); } updateOrganization(id, data) { return this.put(`/organizations/${id}`, data); }
classifyEntity(data) { return this.post('/organizations/classify', data); } classifyEntity(data) { return this.post('/organizations/classify', data); }
// Cruscotto Studio (dashboard consulente) — portfolio aggregato read-only.
consultantPortfolio() { return this.get('/consultant/portfolio'); }
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// Assessments // Assessments
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -399,6 +450,9 @@ class NIS2API {
updateRisk(id, data) { return this.put(`/risks/${id}`, data); } updateRisk(id, data) { return this.put(`/risks/${id}`, data); }
deleteRisk(id) { return this.del(`/risks/${id}`); } deleteRisk(id) { return this.del(`/risks/${id}`); }
getRiskMatrix() { return this.get('/risks/matrix'); } getRiskMatrix() { return this.get('/risks/matrix'); }
derivedRisks() { return this.get('/risks/derived'); }
requisitoActions(requisitoId) { return this.get('/risks/requisitoActions?requisito_id=' + encodeURIComponent(requisitoId)); }
createRequisitoAction(d) { return this.post('/risks/requisitoActions', d || {}); }
aiSuggestRisks() { return this.post('/risks/ai-suggest', {}); } aiSuggestRisks() { return this.post('/risks/ai-suggest', {}); }
// FAIR quantitativo + KRI (P2) // FAIR quantitativo + KRI (P2)
computeFair(id, data) { return this.post(`/risks/${id}/fair`, data); } computeFair(id, data) { return this.post(`/risks/${id}/fair`, data); }
@@ -470,6 +524,8 @@ class NIS2API {
importAssets(data) { return this.post('/assets/import', data); } // P2 import CMDB/CSV importAssets(data) { return this.post('/assets/import', data); } // P2 import CMDB/CSV
listAssetSubclasses() { return this.get('/assets/subclasses'); } // C4 — voci + sottoclassi listAssetSubclasses() { return this.get('/assets/subclasses'); } // C4 — voci + sottoclassi
addAssetSubclass(data) { return this.post('/assets/subclasses', data); } // C4 — nuova sottoclasse org addAssetSubclass(data) { return this.post('/assets/subclasses', data); } // C4 — nuova sottoclasse org
updateAssetSubclass(id, data) { return this.put('/assets/subclasses/' + id, data); } // C4 — rinomina sottoclasse org (#426)
deleteAssetSubclass(id) { return this.del('/assets/subclasses/' + id); } // C4 — rimuove sottoclasse org (#426)
getControlsMonitoring() { return this.get('/audit/controlsMonitoring'); } getControlsMonitoring() { return this.get('/audit/controlsMonitoring'); }
getAcnRequirements() { return this.get('/audit/acnRequirements'); } // requisiti ACN per org getAcnRequirements() { return this.get('/audit/acnRequirements'); } // requisiti ACN per org
updateAcnRequirement(id, status, note) { return this.put(`/audit/acnRequirements/${id}`, { status, evidence_note: note }); } updateAcnRequirement(id, status, note) { return this.put(`/audit/acnRequirements/${id}`, { status, evidence_note: note }); }
@@ -556,6 +612,14 @@ class NIS2API {
getFeedback(id) { return this.get(`/feedback/${id}`); } getFeedback(id) { return this.get(`/feedback/${id}`); }
updateFeedback(id, data) { return this.put(`/feedback/${id}`, data); } updateFeedback(id, data) { return this.put(`/feedback/${id}`, data); }
resolveFeedback(id, password) { return this.post(`/feedback/${id}/resolve`, { password }); } resolveFeedback(id, password) { return this.post(`/feedback/${id}/resolve`, { password }); }
// ── Connettori Discovery (mappatura rete/cloud → auto-popola Inventario) ──
listDiscoveryConnectors() { return this.get('/discovery-connectors/list'); }
createDiscoveryConnector(data) { return this.post('/discovery-connectors/create', data); }
getDiscoveryConnector(id) { return this.get(`/discovery-connectors/${id}`); }
updateDiscoveryConnector(id, d){ return this.put(`/discovery-connectors/${id}`, d); }
deleteDiscoveryConnector(id) { return this.del(`/discovery-connectors/${id}`); }
rotateDiscoveryKey(id) { return this.post(`/discovery-connectors/${id}/rotateKey`, {}); }
} }
// Singleton globale // Singleton globale
+157
View File
@@ -0,0 +1,157 @@
/**
* NIS2 Agile - ARIA Guide (puntatore "dove andare")
*
* Porting del pattern TRPG (CHANGE_aria_voice_pointer.md): quando l'utente chiede
* "dove trovo X", ARIA mostra un PUNTATORE animato + spotlight sulla voce reale
* della sidebar V2 (common-bi.js) e offre "Vai →".
*
* Self-contained, vanilla, frontend-only. Nessuna dipendenza, niente inline-CSS.
* Aggancia le voci via [data-nav="<href senza .html>"] (vedi common-bi.js).
* Rispetta prefers-reduced-motion. Espone window.AriaGuide.
*/
(function () {
'use strict';
if (window.AriaGuide) return;
// Mappa destinazioni: nav (data-nav) → { label, keywords[] }. label = voce reale del menu.
var DESTINATIONS = [
{ nav: 'assets', label: 'Inventario', section: 'Operativo', keywords: ['inventario', 'asset', 'beni', 'bene', 'sistemi informativi', 'rilevanza nis2'] },
{ nav: 'risks', label: 'Rischi', section: 'Gestione', keywords: ['rischio', 'rischi', 'valutazione del rischio', 'risk', 'matrice rischi', 'trattamento'] },
{ nav: 'incidents', label: 'Incidenti', section: 'Gestione', keywords: ['incidente', 'incidenti', 'notifica', 'art. 25', 'art.25', 'csirt', 'data breach', '24 ore', '72 ore'] },
{ nav: 'assessment', label: 'Gap Analysis', section: 'Principale', keywords: ['gap analysis', 'gap', 'assessment', 'questionario', 'autovalutazione', '80 domande'] },
{ nav: 'misure-requisiti', label: 'Misure e Requisiti', section: 'Principale', keywords: ['misure', 'requisiti', 'misura', 'requisito', 'controlli acn', 'determinazione acn'] },
{ nav: 'isms', label: 'Modello SGSI (ISO 27001)', section: 'Principale', keywords: ['sgsi', 'iso 27001', 'isms', 'soa', 'statement of applicability', 'modello organizzativo', 'modello sgsi'] },
{ nav: 'policies', label: 'Policy', section: 'Gestione', keywords: ['policy', 'politiche', 'politica', 'procedura', 'procedure', 'documento normativo'] },
{ nav: 'supply-chain', label: 'Supply Chain', section: 'Gestione', keywords: ['supply chain', 'fornitori', 'fornitore', 'catena di fornitura', 'terze parti'] },
{ nav: 'training', label: 'Formazione', section: 'Operativo', keywords: ['formazione', 'corsi', 'corso', 'training', 'awareness', 'sensibilizzazione'] },
{ nav: 'whistleblowing', label: 'Segnalazioni', section: 'Gestione', keywords: ['segnalazione', 'segnalazioni', 'whistleblowing', 'illeciti', 'canale segnalazione'] },
{ nav: 'normative', label: 'Normative', section: 'Gestione', keywords: ['normativa', 'normative', 'aggiornamenti normativi', 'decreto', 'feed normativo'] },
{ nav: 'stakeholders', label: 'Stakeholder', section: 'Gestione', keywords: ['stakeholder', 'portatori di interesse', 'mendelow', 'matrice stakeholder'] },
{ nav: 'stakeholder-activities', label: 'Attività stakeholder', section: 'Gestione', keywords: ['attività stakeholder', 'questionari stakeholder', 'azioni stakeholder'] },
{ nav: 'organigramma', label: 'Organigramma', section: 'Gestione', keywords: ['organigramma', 'ruoli', 'governance', 'raci', 'responsabilità'] },
{ nav: 'competenze', label: 'Competenze', section: 'Gestione', keywords: ['competenze', 'skill', 'competenza', 'requisiti ruolo'] },
{ nav: 'dashboard', label: 'Dashboard', section: 'Principale', keywords: ['dashboard', 'panoramica', 'home', 'punteggio di compliance', 'compliance score', 'cruscotto'] },
{ nav: 'calendario', label: 'Calendario', section: 'Principale', keywords: ['calendario', 'scadenze', 'scadenziario', 'deadline'] },
{ nav: 'workflow', label: 'Compliance Journey', section: 'Principale', keywords: ['compliance journey', 'percorso di conformità', 'journey', 'roadmap'] },
{ nav: 'internal-audits', label: 'Audit interni', section: 'Operativo', keywords: ['audit interni', 'audit interno', '9.2', 'verifica interna'] },
{ nav: 'controlli-periodici', label: 'Controlli periodici', section: 'Operativo', keywords: ['controlli periodici', 'controllo periodico', 'monitoraggio controlli'] },
{ nav: 'management-review', label: 'Riesame di Direzione', section: 'Operativo', keywords: ['riesame di direzione', 'riesame', 'management review', '9.3'] },
{ nav: 'reports', label: 'Audit & Report', section: 'Operativo', keywords: ['report', 'reportistica', 'audit log', 'esporta', 'esportazione', 'audit & report'] },
{ nav: 'kb', label: 'Knowledge Base', section: 'Gestione', keywords: ['knowledge base', 'documenti caricati', 'carica documento', 'base di conoscenza'] },
{ nav: 'cross-analysis', label: 'AI Cross-Analysis', section: 'Gestione', keywords: ['cross analysis', 'cross-analysis', 'analisi incrociata'] },
{ nav: 'settings', label: 'Impostazioni', section: 'Sistema', keywords: ['impostazioni', 'settings', 'membri', 'permessi documentali', 'profilo'] },
{ nav: 'integrazioniext', label: 'Integrazioni Esterne', section: 'Sistema', keywords: ['integrazioni', 'integrazione', 'api esterne', 'webhook'] }
];
function matchDestination(question) {
var q = ' ' + String(question || '').toLowerCase().replace(/[«»"'`]/g, ' ') + ' ';
var best = null, bestLen = 0;
for (var i = 0; i < DESTINATIONS.length; i++) {
var d = DESTINATIONS[i];
for (var k = 0; k < d.keywords.length; k++) {
var kw = d.keywords[k];
if (kw.length >= 3 && q.indexOf(kw) !== -1 && kw.length > bestLen) {
best = d; bestLen = kw.length;
}
}
}
return best;
}
// ── CSS (iniettato una volta) ───────────────────────────────────────────
var _css = false;
function injectCss() {
if (_css) return; _css = true;
var s = document.createElement('style');
s.textContent = [
'.aria-guide-ov{position:fixed;inset:0;z-index:10050;background:transparent}',
'.aria-guide-spot{position:fixed;z-index:10051;border:3px solid #0066CC;border-radius:10px;',
' box-shadow:0 0 0 9999px rgba(15,23,42,.45),0 0 14px rgba(0,102,204,.7);pointer-events:none;',
' transition:all .35s cubic-bezier(.4,0,.2,1);animation:aria-pulse 1.4s ease-in-out infinite}',
'@keyframes aria-pulse{0%,100%{box-shadow:0 0 0 9999px rgba(15,23,42,.45),0 0 0 0 rgba(0,102,204,.55)}',
' 50%{box-shadow:0 0 0 9999px rgba(15,23,42,.45),0 0 0 8px rgba(0,102,204,0)}}',
'.aria-guide-ptr{position:fixed;z-index:10053;width:34px;height:34px;pointer-events:none;',
' transition:left .6s cubic-bezier(.4,0,.2,1),top .6s cubic-bezier(.4,0,.2,1);filter:drop-shadow(0 2px 3px rgba(0,0,0,.4))}',
'.aria-guide-coach{position:fixed;z-index:10052;max-width:260px;background:#0b2a4a;color:#fff;',
' padding:10px 12px;border-radius:10px;font:500 13px/1.45 system-ui,Arial,sans-serif;',
' box-shadow:0 8px 24px rgba(0,0,0,.3)}',
'.aria-guide-coach b{color:#7ec6ff}',
'.aria-guide-coach .row{margin-top:8px;display:flex;gap:8px;justify-content:flex-end}',
'.aria-guide-coach button{border:none;border-radius:7px;padding:6px 12px;font:600 12px system-ui;cursor:pointer}',
'.aria-guide-go{background:#0066CC;color:#fff}.aria-guide-x{background:#1e3a5f;color:#cbd5e1}',
'.nav-item.aria-guide-open .mega,.nav-item.aria-guide-open .mega.no-promo{display:flex!important}',
'@media (prefers-reduced-motion:reduce){.aria-guide-spot,.aria-guide-ptr{transition:none;animation:none}}'
].join('\n');
document.head.appendChild(s);
}
var _open = null, _openMega = null;
function dismiss() {
if (_openMega) { _openMega.classList.remove('aria-guide-open'); _openMega = null; }
if (!_open) return;
_open.forEach(function (el) { if (el && el.parentNode) el.parentNode.removeChild(el); });
document.removeEventListener('keydown', onKey, true);
_open = null;
}
function onKey(e) { if (e.key === 'Escape') dismiss(); }
/**
* Mostra il puntatore sulla voce di menu `nav` (data-nav). Se la voce non è
* visibile (sidebar mobile chiusa / pagina senza sidebar) naviga direttamente.
* @returns {boolean} true se ha mostrato la guida, false se ha fatto fallback.
*/
function pointTo(nav, label) {
dismiss();
injectCss();
// V3: menu IN ALTO (data-nav su voci dirette e mega-link). Fallback compat sidebar V2.
var el = document.querySelector('.bar-nav [data-nav="' + nav + '"]')
|| document.querySelector('[data-nav="' + nav + '"]')
|| document.querySelector('.sidebar-nav a[data-nav="' + nav + '"]');
var href = el ? (el.getAttribute('href') || (nav + '.html')) : (nav + '.html');
// Se la voce è dentro un mega-menu (dropdown chiuso), aprilo per renderla visibile.
var megaItem = (el && el.closest && el.closest('.mega')) ? el.closest('.nav-item') : null;
if (megaItem) megaItem.classList.add('aria-guide-open');
try { window.scrollTo({ top: 0, behavior: 'auto' }); } catch (e) {}
var rect = el && el.getBoundingClientRect();
// fallback: voce non trovata/visibile → vai diretto
if (!el || !rect || rect.width === 0 || rect.height === 0) {
if (megaItem) megaItem.classList.remove('aria-guide-open');
window.location.href = href; return false;
}
var ov = document.createElement('div'); ov.className = 'aria-guide-ov';
var spot = document.createElement('div'); spot.className = 'aria-guide-spot';
var ptr = document.createElement('div'); ptr.className = 'aria-guide-ptr';
ptr.innerHTML = '<svg viewBox="0 0 24 24" fill="#0066CC" stroke="#fff" stroke-width="1.2"><path d="M5 3l14 8-6 1.5L9.5 19 5 3z"/></svg>';
var coach = document.createElement('div'); coach.className = 'aria-guide-coach';
coach.innerHTML = 'Eccolo: <b>' + (label || nav) + '</b><br><span style="opacity:.85">È nel menu in alto. Vuoi che ti ci porti?</span>'
+ '<div class="row"><button class="aria-guide-x" type="button">Chiudi</button><button class="aria-guide-go" type="button">Vai →</button></div>';
document.body.appendChild(ov);
document.body.appendChild(spot);
document.body.appendChild(ptr);
document.body.appendChild(coach);
function place() {
var r = el.getBoundingClientRect();
spot.style.left = (r.left - 4) + 'px'; spot.style.top = (r.top - 4) + 'px';
spot.style.width = (r.width + 8) + 'px'; spot.style.height = (r.height + 8) + 'px';
ptr.style.left = (r.right - 6) + 'px'; ptr.style.top = (r.top + r.height / 2) + 'px';
var cTop = Math.min(r.top, window.innerHeight - 120);
coach.style.left = (r.right + 16) + 'px'; coach.style.top = Math.max(8, cTop) + 'px';
}
// pointer parte dal centro schermo poi va alla voce (animazione)
ptr.style.left = (window.innerWidth / 2) + 'px'; ptr.style.top = (window.innerHeight / 2) + 'px';
requestAnimationFrame(function () { requestAnimationFrame(place); });
ov.addEventListener('click', dismiss);
coach.querySelector('.aria-guide-x').addEventListener('click', dismiss);
coach.querySelector('.aria-guide-go').addEventListener('click', function () { window.location.href = href; });
document.addEventListener('keydown', onKey, true);
_open = [ov, spot, ptr, coach];
_openMega = megaItem;
return true;
}
window.AriaGuide = { pointTo: pointTo, matchDestination: matchDestination, dismiss: dismiss, DESTINATIONS: DESTINATIONS };
})();
+25 -2
View File
@@ -27,6 +27,10 @@
const script = document.currentScript || document.querySelector('script[data-product]'); const script = document.currentScript || document.querySelector('script[data-product]');
const WIDGET_VERSION = '1.4.15'; const WIDGET_VERSION = '1.4.15';
const WIDGET_BUILD = '20260415d'; const WIDGET_BUILD = '20260415d';
// Limite descrizione: la colonna operative_summary lato ticket-ms e' VARCHAR(2000);
// alla descrizione viene aggiunto il tag versione (~30 char) prima dell'invio,
// quindi cappiamo a 1900 per evitare l'errore "Data too long for column 'operative_summary'".
const NX_DESC_MAX = 1900;
const CFG = { const CFG = {
apiUrl: (script && script.dataset.apiUrl) || 'http://localhost', apiUrl: (script && script.dataset.apiUrl) || 'http://localhost',
apiKey: (script && script.dataset.apiKey) || '', apiKey: (script && script.dataset.apiKey) || '',
@@ -471,7 +475,8 @@
<option value="URGENT">Urgente</option> <option value="URGENT">Urgente</option>
</select> </select>
</div> </div>
<textarea class="nx-textarea" id="nx-desc" placeholder="Descrivi il problema... (puoi incollare screenshot con Ctrl+V)"></textarea> <textarea class="nx-textarea" id="nx-desc" maxlength="${NX_DESC_MAX}" placeholder="Descrivi il problema... (puoi incollare screenshot con Ctrl+V)"></textarea>
<div id="nx-desc-counter" aria-live="polite" style="font-size:11px;color:#6b7280;text-align:right;margin-top:-2px;margin-bottom:4px">0 / ${NX_DESC_MAX}</div>
<div class="nx-att-list" id="nx-att-list"></div> <div class="nx-att-list" id="nx-att-list"></div>
<div class="nx-row"> <div class="nx-row">
<button class="nx-btn" id="nx-voice-btn" onclick="window._nxVoice()">🎤 Parla</button> <button class="nx-btn" id="nx-voice-btn" onclick="window._nxVoice()">🎤 Parla</button>
@@ -487,6 +492,16 @@
// Paste handler // Paste handler
const desc = document.getElementById('nx-desc'); const desc = document.getElementById('nx-desc');
// Contatore caratteri live (limite colonna operative_summary)
const counter = document.getElementById('nx-desc-counter');
const updateCounter = () => {
if (!counter) return;
const n = desc.value.length;
counter.textContent = n + ' / ' + NX_DESC_MAX;
counter.style.color = n >= NX_DESC_MAX ? '#dc2626' : '#6b7280';
};
desc.addEventListener('input', updateCounter);
updateCounter();
desc.addEventListener('paste', handlePaste); desc.addEventListener('paste', handlePaste);
// Drag & drop // Drag & drop
desc.addEventListener('dragover', (e) => { e.preventDefault(); desc.classList.add('drag-over'); }); desc.addEventListener('dragover', (e) => { e.preventDefault(); desc.classList.add('drag-over'); });
@@ -613,7 +628,8 @@
btn.style.background = '#fee2e2'; btn.style.background = '#fee2e2';
recognition.onresult = (e) => { recognition.onresult = (e) => {
const desc = document.getElementById('nx-desc'); const desc = document.getElementById('nx-desc');
desc.value += (desc.value ? '\n' : '') + e.results[0][0].transcript; desc.value = (desc.value + (desc.value ? '\n' : '') + e.results[0][0].transcript).slice(0, NX_DESC_MAX);
desc.dispatchEvent(new Event('input'));
btn.textContent = '🎤 Parla'; btn.style.background = ''; btn.textContent = '🎤 Parla'; btn.style.background = '';
}; };
recognition.onerror = recognition.onend = () => { btn.textContent = '🎤 Parla'; btn.style.background = ''; }; recognition.onerror = recognition.onend = () => { btn.textContent = '🎤 Parla'; btn.style.background = ''; };
@@ -629,6 +645,13 @@
const result = document.getElementById('nx-result'); const result = document.getElementById('nx-result');
if (!desc && _attachments.length === 0) { alert('Descrivi il problema o allega uno screenshot'); return; } if (!desc && _attachments.length === 0) { alert('Descrivi il problema o allega uno screenshot'); return; }
if (desc && desc.length > NX_DESC_MAX) {
const result0 = document.getElementById('nx-result');
result0.className = 'nx-result err';
result0.style.display = 'block';
result0.textContent = `❌ Descrizione troppo lunga: massimo ${NX_DESC_MAX} caratteri (ne hai ${desc.length}). Accorcia il testo o allega un file.`;
return;
}
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Invio in corso...'; submitBtn.textContent = 'Invio in corso...';
+3 -1
View File
@@ -91,6 +91,7 @@
items: [ items: [
{ name: 'Formazione', href: 'training.html', icon: iconAcademicCap(), i18nKey: 'nav.training' }, { name: 'Formazione', href: 'training.html', icon: iconAcademicCap(), i18nKey: 'nav.training' },
{ name: 'Inventario', href: 'assets.html', icon: iconServer(), i18nKey: 'nav.assets' }, { name: 'Inventario', href: 'assets.html', icon: iconServer(), i18nKey: 'nav.assets' },
{ name: 'Connettori Discovery', href: 'connettori-discovery.html', icon: '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/></svg>' },
{ name: 'Audit interni', href: 'internal-audits.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm5.707 6.707a1 1 0 00-1.414-1.414L7 10.586l-.293-.293a1 1 0 00-1.414 1.414l1 1a1 1 0 001.414 0l2-2zM10 13a1 1 0 100 2h3a1 1 0 100-2h-3z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.internal_audits' }, { name: 'Audit interni', href: 'internal-audits.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm5.707 6.707a1 1 0 00-1.414-1.414L7 10.586l-.293-.293a1 1 0 00-1.414 1.414l1 1a1 1 0 001.414 0l2-2zM10 13a1 1 0 100 2h3a1 1 0 100-2h-3z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.internal_audits' },
{ name: 'Controlli periodici', href: 'controlli-periodici.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.5 2.5a1 1 0 001.414-1.414L11 9.586V6z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.periodic_controls' }, { name: 'Controlli periodici', href: 'controlli-periodici.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.5 2.5a1 1 0 001.414-1.414L11 9.586V6z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.periodic_controls' },
{ name: 'Riesame di Direzione', href: 'management-review.html', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>`, i18nKey: 'nav.management_review' }, { name: 'Riesame di Direzione', href: 'management-review.html', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>`, i18nKey: 'nav.management_review' },
@@ -136,7 +137,8 @@
var cls = isActive ? 'active' : ''; var cls = isActive ? 'active' : '';
var aria = isActive ? ' aria-current="page"' : ''; var aria = isActive ? ' aria-current="page"' : '';
var spanI18n = item.i18nKey ? ' data-i18n="' + item.i18nKey + '"' : ''; var spanI18n = item.i18nKey ? ' data-i18n="' + item.i18nKey + '"' : '';
html += '<a href="' + biHref(item.href) + '" class="' + cls + '"' + aria + '>' + var navKey = item.href.replace(/\.html$/, ''); // aggancio puntatore ARIA (aria-guide.js)
html += '<a href="' + biHref(item.href) + '" class="' + cls + '"' + aria + ' data-nav="' + navKey + '">' +
item.icon + '<span' + spanI18n + '>' + item.name + '</span>' + item.icon + '<span' + spanI18n + '>' + item.name + '</span>' +
'</a>'; '</a>';
}); });
+211 -23
View File
@@ -507,11 +507,17 @@ async function _loadFirmBranding() {
// ── Versioning live (Fase 4 / G13) ────────────────────────────────────── // ── Versioning live (Fase 4 / G13) ──────────────────────────────────────
let _versionInfo = null; let _versionInfo = null;
let _loadedVersion = null; // versione con cui l'app è stata caricata (per il check aggiornamenti)
let _updateWatchStarted = false;
async function _loadVersionFooter() { async function _loadVersionFooter() {
try { try {
const r = await fetch('/version.json?_=' + Date.now()); const r = await fetch('/version.json?_=' + Date.now());
if (!r.ok) return; if (!r.ok) return;
_versionInfo = await r.json(); _versionInfo = await r.json();
if (_loadedVersion === null && _versionInfo && _versionInfo.version) {
_loadedVersion = _versionInfo.version;
_startUpdateWatch();
}
const el = document.getElementById('sidebar-version'); const el = document.getElementById('sidebar-version');
if (el && _versionInfo.version) { if (el && _versionInfo.version) {
el.textContent = 'v' + _versionInfo.version + (_versionInfo.build ? ' · ' + _versionInfo.build : ''); el.textContent = 'v' + _versionInfo.version + (_versionInfo.build ? ' · ' + _versionInfo.build : '');
@@ -524,6 +530,52 @@ function _showVersionChangelog() {
alert('NIS2 Agile v' + v.version + '\nBuild: ' + (v.build || '—') + '\nData: ' + (v.date || '—') + '\n\n' + (v.changelog || 'Nessun changelog disponibile')); alert('NIS2 Agile v' + v.version + '\nBuild: ' + (v.build || '—') + '\nData: ' + (v.date || '—') + '\n\n' + (v.changelog || 'Nessun changelog disponibile'));
} }
// ── Gestione release: avvisa (senza forzare) quando è online una nuova versione ──
// Poll di version.json ogni 5 min + al ritorno sulla scheda; se la versione è cambiata
// mostra un banner "Aggiorna" che ricarica (asset freschi via cache-buster + service worker).
// Al ritorno sulla scheda rinnova anche il token (evita la sessione scaduta dopo sospensione).
function _startUpdateWatch() {
if (_updateWatchStarted) return;
_updateWatchStarted = true;
setInterval(_checkForUpdate, 5 * 60 * 1000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
_checkForUpdate();
if (typeof api !== 'undefined' && api.ensureFreshToken) api.ensureFreshToken();
}
});
_swUpdate();
}
function _swUpdate() {
try { if (navigator.serviceWorker) navigator.serviceWorker.getRegistration().then(r => { if (r) r.update(); }).catch(() => {}); } catch (e) { /* noop */ }
}
async function _checkForUpdate() {
try {
const r = await fetch('/version.json?_=' + Date.now(), { cache: 'no-store' });
if (!r.ok) return;
const info = await r.json();
if (info && info.version && _loadedVersion && info.version !== _loadedVersion) {
_showUpdateBanner(info.version);
}
_swUpdate();
} catch (e) { /* silenzioso */ }
}
function _showUpdateBanner(newVersion) {
if (document.getElementById('nis2-update-banner')) return;
const b = document.createElement('div');
b.id = 'nis2-update-banner';
b.style.cssText = 'position:fixed;left:50%;bottom:20px;transform:translateX(-50%);z-index:9998;' +
'background:#0A2E54;color:#fff;border-radius:12px;padding:12px 16px;display:flex;align-items:center;gap:14px;' +
'box-shadow:0 10px 30px rgba(11,40,80,.28);font:500 14px/1.4 Inter,system-ui,Arial,sans-serif;max-width:92vw';
b.innerHTML =
'<span>È disponibile una <b>nuova versione</b>' + (newVersion ? ' (v' + escapeHtml(String(newVersion)) + ')' : '') + '. Aggiorna per averla.</span>' +
'<button id="nis2-update-btn" style="border:0;border-radius:8px;background:#F0B429;color:#1a1a1a;font-weight:700;padding:8px 14px;cursor:pointer">Aggiorna</button>' +
'<button id="nis2-update-x" aria-label="Chiudi" style="border:0;background:transparent;color:#cfe0f1;font-size:18px;cursor:pointer;line-height:1">×</button>';
document.body.appendChild(b);
document.getElementById('nis2-update-btn').addEventListener('click', () => location.reload());
document.getElementById('nis2-update-x').addEventListener('click', () => b.remove());
}
function _setupMobileToggle() { function _setupMobileToggle() {
// Crea pulsante toggle se non esiste // Crea pulsante toggle se non esiste
if (!document.querySelector('.sidebar-toggle')) { if (!document.querySelector('.sidebar-toggle')) {
@@ -952,22 +1004,42 @@ function switchLang(lang) {
// ── 1. Inietta il bug-reporter Nexus ──────────────────────────────────── // ── 1. Inietta il bug-reporter Nexus ────────────────────────────────────
if (!window.__nexusWidgetLoaded) { if (!window.__nexusWidgetLoaded) {
window.__nexusWidgetLoaded = true; window.__nexusWidgetLoaded = true;
var s = document.createElement('script'); // [#419 fix] L'email NON e' nelle claims del JWT NIS2 (claims.email vuoto) → se manca,
s.src = 'js/bug-reporter.js?v=20260613'; // recuperala dal profilo via api.getMe() PRIMA di iniettare. Senza email il widget crea
s.async = true; // ticket con richiedente vuoto → invisibili in "Le mie segnalazioni". Fallback safe = prec.
s.dataset.product = 'NIS2'; var _injectBugReporter = function (email, name) {
s.dataset.tenantId = '7'; var s = document.createElement('script');
s.dataset.apiUrl = 'https://agilehub.agile.software'; s.src = 'js/bug-reporter.js?v=20260624';
// X-API-Key per il gateway ticket AgileHub. Senza, il widget riceve 401 s.async = true;
// "Header X-API-Key o Authorization: Bearer richiesto". Valorizzare con la s.dataset.product = 'NIS2';
// chiave NIS2 emessa da AgileHub (prodotto NIS2, tenant 7). Inietta via s.dataset.tenantId = '7';
// window.NEXUS_WIDGET_API_KEY oppure hardcoda qui quando disponibile. s.dataset.apiUrl = 'https://agilehub.agile.software';
s.dataset.apiKey = (window.NEXUS_WIDGET_API_KEY || 'nis2_ak_dev_109b4bb54557c4ab3bdbed45'); s.dataset.apiKey = (window.NEXUS_WIDGET_API_KEY || 'nis2_ak_dev_109b4bb54557c4ab3bdbed45');
s.dataset.userName = userName; s.dataset.userName = name || userName || '';
s.dataset.userEmail = userEmail; s.dataset.userEmail = email || userEmail || '';
s.dataset.userRole = userRole; s.dataset.userRole = userRole;
s.dataset.lang = 'it'; s.dataset.lang = 'it';
document.body.appendChild(s); document.body.appendChild(s);
};
if (userEmail) {
_injectBugReporter(userEmail, userName);
} else if (typeof api !== 'undefined' && typeof api.getMe === 'function') {
api.getMe().then(function (r) {
var u = (r && r.data) || {};
_injectBugReporter(u.email || '', u.full_name || u.name || '');
}).catch(function () { _injectBugReporter('', ''); });
} else {
_injectBugReporter('', '');
}
}
// ── 1c. Inietta il modulo guida ARIA (puntatore/spotlight verso il menu) ──
if (!window.__ariaGuideLoaded) {
window.__ariaGuideLoaded = true;
var ag = document.createElement('script');
ag.src = 'js/aria-guide.js?v=20260627t';
ag.async = true;
document.body.appendChild(ag);
} }
// ── 2. AI Chat FAB viola "ARIA" ───────────────────────────────────────── // ── 2. AI Chat FAB viola "ARIA" ─────────────────────────────────────────
@@ -994,10 +1066,62 @@ function switchLang(lang) {
+ "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"; + "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;";
document.body.appendChild(panel); document.body.appendChild(panel);
// ── Voce ARIA naturale (TTS via /api/ai/tts → nexus-voice-ms) ─────────────
var ttsMuted = localStorage.getItem('nis2_aria_voice') === 'off';
var ttsAudio = null;
function _ttsPlain(s) {
return String(s || '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[#*_>`|]/g, ' ')
.replace(/\s+/g, ' ').trim().slice(0, 800);
}
function speak(text) {
if (ttsMuted) return;
var clean = _ttsPlain(text);
if (!clean) return;
var tok = localStorage.getItem('nis2_access_token') || '';
fetch('/api/ai/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': tok ? ('Bearer ' + tok) : '' },
body: JSON.stringify({ text: clean })
}).then(function (r) { return r.ok ? r.blob() : null; })
.then(function (b) {
if (!b || ttsMuted) return;
try { if (ttsAudio) ttsAudio.pause(); } catch (e) {}
ttsAudio = new Audio(URL.createObjectURL(b));
ttsAudio.volume = 0.55;
ttsAudio.play().catch(function () {}); // autoplay bloccato → silenzioso
}).catch(function () {});
}
function toggleVoice() {
ttsMuted = !ttsMuted;
localStorage.setItem('nis2_aria_voice', ttsMuted ? 'off' : 'on');
if (ttsMuted && ttsAudio) { try { ttsAudio.pause(); } catch (e) {} }
var vb = document.getElementById('ai-chat-voice');
if (vb) vb.textContent = ttsMuted ? '🔇' : '🔊';
}
var STORAGE_KEY = 'nis2_ai_chat'; var STORAGE_KEY = 'nis2_ai_chat';
var history = []; var history = [];
var pending = false; // ARIA sta elaborando una risposta (mostra indicatore "sta scrivendo")
try { history = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '[]'); } catch (e) {} try { history = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '[]'); } catch (e) {}
// Stile una-tantum per l'indicatore "ARIA sta scrivendo…" (puntini animati,
// rispetta prefers-reduced-motion per accessibilita AGID/WCAG).
if (!document.getElementById('nis2-aria-style')) {
var ariaStyle = document.createElement('style');
ariaStyle.id = 'nis2-aria-style';
ariaStyle.textContent =
'@keyframes nis2AriaBlink{0%,80%,100%{opacity:.2}40%{opacity:1}}'
+ '.nis2-aria-dot{display:inline-block;width:6px;height:6px;margin:0 1px;'
+ 'border-radius:50%;background:#7C3AED;animation:nis2AriaBlink 1.2s infinite both}'
+ '.nis2-aria-dot:nth-child(2){animation-delay:.2s}'
+ '.nis2-aria-dot:nth-child(3){animation-delay:.4s}'
+ '@media (prefers-reduced-motion:reduce){.nis2-aria-dot{animation:none;opacity:.6}}';
document.head.appendChild(ariaStyle);
}
function escHtml(s) { function escHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) { return String(s).replace(/[&<>"']/g, function (c) {
return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c];
@@ -1035,6 +1159,8 @@ function switchLang(lang) {
+ '<div style="display:flex;align-items:center;gap:8px;font-weight:600">' + '<div style="display:flex;align-items:center;gap:8px;font-weight:600">'
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M12 3l1.8 4.6L18.5 9.5l-4.7 1.9L12 16l-1.8-4.6L5.5 9.5l4.7-1.9L12 3z"/></svg> ARIA — Assistente NIS2' + '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M12 3l1.8 4.6L18.5 9.5l-4.7 1.9L12 16l-1.8-4.6L5.5 9.5l4.7-1.9L12 3z"/></svg> ARIA — Assistente NIS2'
+ '</div>' + '</div>'
+ '<button id="ai-chat-voice" aria-label="Attiva/disattiva voce" title="Voce ARIA" style="background:none;'
+ 'border:none;color:#fff;font-size:15px;cursor:pointer;margin-right:2px">' + (ttsMuted ? '🔇' : '🔊') + '</button>'
+ '<button id="ai-chat-close" aria-label="Chiudi" style="background:none;' + '<button id="ai-chat-close" aria-label="Chiudi" style="background:none;'
+ 'border:none;color:#fff;font-size:18px;cursor:pointer">&times;</button>' + 'border:none;color:#fff;font-size:18px;cursor:pointer">&times;</button>'
+ '</div>' + '</div>'
@@ -1043,36 +1169,79 @@ function switchLang(lang) {
+ (list || '<div style="color:#9ca3af;font-size:13px;text-align:center;' + (list || '<div style="color:#9ca3af;font-size:13px;text-align:center;'
+ 'margin-top:24px">Ciao! Chiedimi qualcosa su NIS2: misure di ' + 'margin-top:24px">Ciao! Chiedimi qualcosa su NIS2: misure di '
+ 'sicurezza, audit, fornitori, incident response…</div>') + 'sicurezza, audit, fornitori, incident response…</div>')
+ (pending
? '<div id="ai-chat-typing" aria-live="polite" '
+ 'style="margin-bottom:10px;display:flex;justify-content:flex-start">'
+ '<div style="padding:8px 12px;border-radius:14px;font-size:13px;'
+ 'background:#f3f4f6;color:#6b7280">ARIA sta scrivendo '
+ '<span class="nis2-aria-dot"></span><span class="nis2-aria-dot"></span>'
+ '<span class="nis2-aria-dot"></span></div></div>'
: '')
+ '</div>' + '</div>'
+ '<form id="ai-chat-form" style="display:flex;gap:6px;padding:10px;' + '<form id="ai-chat-form" style="display:flex;gap:6px;padding:10px;'
+ 'border-top:1px solid #e5e7eb;background:#fff">' + 'border-top:1px solid #e5e7eb;background:#fff;align-items:flex-end">'
+ '<input id="ai-chat-input" type="text" placeholder="Scrivi…" ' + '<textarea id="ai-chat-input" rows="1" '
+ 'autocomplete="off" style="flex:1;padding:10px 12px;border:1px solid ' + 'placeholder="Scrivi… (Invio per inviare, Shift+Invio per andare a capo)" '
+ '#d1d5db;border-radius:10px;font-size:14px;font-family:inherit">' + 'aria-label="Scrivi un messaggio ad ARIA" autocomplete="off"'
+ '<button type="submit" style="padding:10px 14px;border:none;' + (pending ? ' disabled' : '')
+ ' style="flex:1;padding:10px 12px;border:1px solid #d1d5db;border-radius:10px;'
+ 'font-size:14px;font-family:inherit;resize:none;max-height:120px;'
+ 'overflow-y:auto;line-height:1.4"></textarea>'
+ '<button type="submit"' + (pending ? ' disabled' : '')
+ ' style="padding:10px 14px;border:none;'
+ 'border-radius:10px;background:linear-gradient(135deg,#7C3AED,#3B82F6);' + 'border-radius:10px;background:linear-gradient(135deg,#7C3AED,#3B82F6);'
+ 'color:#fff;font-weight:600;cursor:pointer">' + 'color:#fff;font-weight:600;cursor:' + (pending ? 'not-allowed' : 'pointer')
+ ';opacity:' + (pending ? '.6' : '1') + '">'
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' + '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
+ '</button>' + '</button>'
+ '</form>'; + '</form>';
document.getElementById('ai-chat-close').onclick = closePanel; document.getElementById('ai-chat-close').onclick = closePanel;
var _vb = document.getElementById('ai-chat-voice');
if (_vb) _vb.onclick = toggleVoice;
document.getElementById('ai-chat-form').onsubmit = onSubmit; document.getElementById('ai-chat-form').onsubmit = onSubmit;
var inputEl = document.getElementById('ai-chat-input');
if (inputEl) {
// Invio = invia · Shift+Invio = vai a capo (textarea multi-riga)
inputEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSubmit(e); }
});
// Auto-crescita altezza della textarea fino a max-height
inputEl.addEventListener('input', function () {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
});
if (!pending) inputEl.focus();
}
var msgs = document.getElementById('ai-chat-msgs'); var msgs = document.getElementById('ai-chat-msgs');
msgs.scrollTop = msgs.scrollHeight; msgs.scrollTop = msgs.scrollHeight;
} }
async function onSubmit(ev) { async function onSubmit(ev) {
ev.preventDefault(); ev.preventDefault();
if (pending) return; // evita invii doppi mentre ARIA elabora
var input = document.getElementById('ai-chat-input'); var input = document.getElementById('ai-chat-input');
var text = (input.value || '').trim(); var text = (input.value || '').trim();
if (!text) return; if (!text) return;
history.push({ role: 'user', content: text }); history.push({ role: 'user', content: text });
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(history)); sessionStorage.setItem(STORAGE_KEY, JSON.stringify(history));
pending = true;
render(); render();
try { try {
// AI nativa NIS2 (RAG con KB Multi-Livello già attivo) // AI nativa NIS2 (RAG con KB Multi-Livello già attivo)
var t = localStorage.getItem('nis2_access_token') || ''; var t = localStorage.getItem('nis2_access_token') || '';
var orgId = localStorage.getItem('nis2_org_id') || ''; var orgId = localStorage.getItem('nis2_org_id') || '';
// Pagina corrente: aiuta ARIA a dare indicazioni contestuali (ticket #424).
var pageName = (document.title || '').replace(/\s*[—|·-]\s*NIS2.*$/i, '').trim()
|| (location.pathname.split('/').pop() || '');
// pageId canonico + testo dell'help "?" della pagina → ARIA risponde
// ALLINEATA a ciò che l'utente vede nella guida (fonte unica help.js).
var pageId = '', pageHelp = '';
try {
if (typeof HelpSystem !== 'undefined') {
if (HelpSystem.detectPageId) pageId = HelpSystem.detectPageId() || '';
if (HelpSystem.getContextText) pageHelp = HelpSystem.getContextText(pageId) || '';
}
} catch (e) { /* help non disponibile → ARIA prosegue senza */ }
var r = await fetch('/api/ai/ask', { var r = await fetch('/api/ai/ask', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -1080,7 +1249,7 @@ function switchLang(lang) {
'Authorization': t ? ('Bearer ' + t) : '', 'Authorization': t ? ('Bearer ' + t) : '',
'X-Organization-Id': orgId 'X-Organization-Id': orgId
}, },
body: JSON.stringify({ question: text, history: history }) body: JSON.stringify({ question: text, history: history, page: pageName, page_id: pageId, page_help: pageHelp })
}); });
var resp = await r.json(); var resp = await r.json();
var answer = (resp && resp.success && resp.data var answer = (resp && resp.success && resp.data
@@ -1088,14 +1257,33 @@ function switchLang(lang) {
|| (resp && (resp.answer || resp.message || resp.text)) || (resp && (resp.answer || resp.message || resp.text))
|| 'ARIA non ha risposto. Riprova tra poco.'; || 'ARIA non ha risposto. Riprova tra poco.';
history.push({ role: 'assistant', content: answer }); history.push({ role: 'assistant', content: answer });
try { speak(answer); } catch (e2) {} // voce naturale ARIA (se non mutata)
} catch (e) { } catch (e) {
history.push({ history.push({
role: 'assistant', role: 'assistant',
content: '⚠️ ARIA non risponde in questo momento. Riprova tra poco.' content: '⚠️ ARIA non risponde in questo momento. Riprova tra poco.'
}); });
} }
pending = false;
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(history)); sessionStorage.setItem(STORAGE_KEY, JSON.stringify(history));
render(); render();
// Guida "Mostrami dov'è": se la domanda combacia con una voce di menu,
// offri il puntatore/spotlight verso la sidebar (porting TRPG aria-guide).
try {
var dest = (window.AriaGuide && AriaGuide.matchDestination) ? AriaGuide.matchDestination(text) : null;
if (dest) {
var msgsEl = document.getElementById('ai-chat-msgs');
if (msgsEl) {
var cta = document.createElement('button');
cta.type = 'button';
cta.style.cssText = 'margin:6px 0 2px;align-self:flex-start;background:#eef5ff;color:#0066CC;border:1px solid #b3d4f5;border-radius:10px;padding:7px 12px;font:600 13px system-ui;cursor:pointer';
cta.textContent = '📍 Mostrami dov’è: ' + dest.label;
cta.addEventListener('click', function () { if (window.AriaGuide) AriaGuide.pointTo(dest.nav, dest.label); });
msgsEl.appendChild(cta);
msgsEl.scrollTop = msgsEl.scrollHeight;
}
}
} catch (e) { /* guida opzionale */ }
} }
fab.onclick = function () { fab.onclick = function () {
+115
View File
@@ -0,0 +1,115 @@
/**
* NIS2 Agile — Gestione Connettori Discovery (mappatura rete/cloud → auto-popola Inventario).
* Usa api.js (CRUD) + helper di common.js (escHtml/showModal/closeModal/showNotification).
*/
(function () {
'use strict';
function esc(s) {
s = (s == null) ? '' : String(s);
return (typeof escHtml === 'function') ? escHtml(s)
: s.replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; });
}
function notify(m, t) { if (typeof showNotification === 'function') showNotification(m, t || 'info'); else alert(m); }
function fmtDate(s) { if (!s) return '—'; try { return new Date(String(s).replace(' ', 'T')).toLocaleString('it-IT', { timeZone: 'Europe/Rome' }); } catch (e) { return s; } }
var INGEST_URL = location.origin + '/api/services/assets-ingest';
async function loadList() {
var el = document.getElementById('dc-list');
el.innerHTML = 'Caricamento…';
var r = await api.get('/discovery-connectors/list');
if (!r || !r.success) { el.innerHTML = '<div class="alert alert-danger">Errore nel caricamento.</div>'; return; }
var rows = (r.data && r.data.connectors) || [];
if (!rows.length) { el.innerHTML = '<p style="color:var(--gray-600)">Nessun connettore. Creane uno con «+ Nuovo connettore».</p>'; return; }
var html = '<table class="table"><thead><tr><th>Nome</th><th>Tipo</th><th>Stato</th><th>Ultimo run</th><th>Asset</th><th>Chiave</th><th></th></tr></thead><tbody>';
rows.forEach(function (c) {
var badge = c.status === 'active' ? 'success' : (c.status === 'paused' ? 'secondary' : 'danger');
var nm = esc(c.name).replace(/'/g, '');
html += '<tr>'
+ '<td><strong>' + esc(c.name) + '</strong></td>'
+ '<td>' + esc(c.connector_type) + '</td>'
+ '<td><span class="badge bg-' + badge + '">' + esc(c.status) + '</span></td>'
+ '<td>' + fmtDate(c.last_run_at) + (c.last_status ? (' <small>(' + esc(c.last_status) + ')</small>') : '') + '</td>'
+ '<td>' + (c.last_discovered != null ? esc(c.last_discovered) : '—') + '</td>'
+ '<td><code>' + esc(c.key_prefix || '—') + '…</code></td>'
+ '<td style="white-space:nowrap">'
+ '<button class="btn btn-sm btn-outline-primary" onclick="DiscoveryUI.detail(' + c.id + ')">Dettaglio</button> '
+ '<button class="btn btn-sm btn-outline-secondary" onclick="DiscoveryUI.rotate(' + c.id + ',\'' + nm + '\')">Rigenera chiave</button> '
+ '<button class="btn btn-sm btn-outline-danger" onclick="DiscoveryUI.remove(' + c.id + ',\'' + nm + '\')">Elimina</button>'
+ '</td></tr>';
});
html += '</tbody></table>';
el.innerHTML = html;
}
function keyModal(title, rawKey, name) {
var cmd = 'NIS2_API_KEY=' + rawKey + ' TARGETS=192.168.1.0/24 node discovery-agent.mjs';
var body = '<p>Copia ORA questa chiave: <strong>non sarà più visibile</strong>.</p>'
+ '<div class="form-group"><label>API key</label><input class="form-control" readonly value="' + esc(rawKey) + '" onclick="this.select()"></div>'
+ '<div class="form-group"><label>Comando agente (esempio)</label><textarea class="form-control" rows="3" readonly onclick="this.select()">' + esc(cmd) + '</textarea></div>'
+ '<p style="color:var(--gray-600);font-size:.9em">Endpoint ingest: <code>' + esc(INGEST_URL) + '</code> · scope <code>ingest:assets</code>. Agente: <code>scripts/discovery-agent.mjs</code>.</p>';
showModal(title, body, { size: 'lg', footer: '<button class="btn btn-primary" onclick="closeModal()">Ho copiato</button>' });
}
async function create() {
var name = (document.getElementById('dc-name').value || '').trim();
if (!name) { notify('Inserisci un nome', 'warning'); return; }
var data = {
name: name,
connector_type: document.getElementById('dc-type').value,
schedule: document.getElementById('dc-schedule').value,
config: { targets: (document.getElementById('dc-targets').value || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean) }
};
var r = await api.post('/discovery-connectors/create', data);
if (r && r.success) {
document.getElementById('dc-form-card').style.display = 'none';
document.getElementById('dc-name').value = '';
document.getElementById('dc-targets').value = '';
keyModal('Connettore creato — chiave API', r.data.api_key, name);
loadList();
} else { notify('Errore: ' + ((r && r.message) || 'sconosciuto'), 'danger'); }
}
async function detail(id) {
var r = await api.get('/discovery-connectors/' + id);
if (!r || !r.success) { notify('Connettore non trovato', 'danger'); return; }
var c = r.data;
var runs = (c.runs || []).map(function (x) {
return '<tr><td>' + fmtDate(x.started_at) + '</td><td>' + esc(x.status) + '</td><td>' + esc(x.discovered_assets) + '</td><td>' + esc(x.discovered_flows) + '</td><td>' + esc(x.message || '') + '</td></tr>';
}).join('');
var targets = ((c.config && c.config.targets) || []).join(', ') || '—';
var body = '<p><strong>Tipo:</strong> ' + esc(c.connector_type) + ' · <strong>Stato:</strong> ' + esc(c.status) + ' · <strong>Schedulazione:</strong> ' + esc(c.schedule) + '</p>'
+ '<p><strong>Target:</strong> ' + esc(targets) + '</p>'
+ '<p><strong>Chiave:</strong> <code>' + esc(c.key_prefix || '—') + '…</code> · <strong>ultimo run:</strong> ' + fmtDate(c.last_run_at) + '</p>'
+ '<h4>Ultimi run</h4>'
+ (runs
? ('<table class="table table-sm"><thead><tr><th>Quando</th><th>Esito</th><th>Asset</th><th>Flussi</th><th>Note</th></tr></thead><tbody>' + runs + '</tbody></table>')
: '<p style="color:var(--gray-600)">Nessun run ancora. Esegui l\'agente di discovery con la chiave di questo connettore.</p>');
showModal('Connettore: ' + esc(c.name), body, { size: 'lg', footer: '<button class="btn btn-outline-primary" onclick="closeModal()">Chiudi</button>' });
}
async function rotate(id, name) {
if (!confirm('Rigenerare la chiave di «' + name + '»? La vecchia smetterà di funzionare.')) return;
var r = await api.post('/discovery-connectors/' + id + '/rotateKey', {});
if (r && r.success) { keyModal('Nuova chiave API', r.data.api_key, name); loadList(); }
else { notify('Errore nella rigenerazione', 'danger'); }
}
async function remove(id, name) {
if (!confirm('Eliminare il connettore «' + name + '»? La sua chiave verrà disattivata.')) return;
var r = await api.del('/discovery-connectors/' + id);
if (r && r.success) { notify('Connettore eliminato', 'success'); loadList(); }
else { notify('Errore nell\'eliminazione', 'danger'); }
}
function init() {
var b = document.getElementById('btn-dc-new');
if (b) b.onclick = function () { var f = document.getElementById('dc-form-card'); f.style.display = (f.style.display === 'none') ? 'block' : 'none'; };
var bc = document.getElementById('btn-dc-create'); if (bc) bc.onclick = create;
var bx = document.getElementById('btn-dc-cancel'); if (bx) bx.onclick = function () { document.getElementById('dc-form-card').style.display = 'none'; };
loadList();
}
window.DiscoveryUI = { init: init, detail: detail, rotate: rotate, remove: remove };
})();
+431
View File
@@ -0,0 +1,431 @@
/* ═══════════════════════════════════════════════════════════════════════════
* dashboard.js — Dashboard V3 (cablata sui DATI REALI via api.js).
* ───────────────────────────────────────────────────────────────────────────
* Markup/classi FEDELI al mockup "docs/mockup Ui/index.html" (.daystrip/.weekstrip/
* .stat/.sca/.act/.anag/.doc), ma ZERO dati finti: tutto da
* /dashboard/overview · /dashboard/compliance-score · /dashboard/upcoming-deadlines
* /dashboard/recent-activity · /organizations/current
* Stati loading/empty/error gestiti. NESSUN endpoint nuovo.
* I monitor giornaliero/settimanale usano gli EVENTI reali (scadenze + attività);
* "Documenti istituzionali" non ha endpoint → stato vuoto onesto (no invenzione).
* ═══════════════════════════════════════════════════════════════════════════ */
(function () {
'use strict';
// ── Date helpers (porting dal mockup, ma TODAY = data reale) ────────────
var TODAY = new Date(); TODAY.setHours(0, 0, 0, 0);
var MO = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
var WDa = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom'];
var WDi = ['L', 'M', 'M', 'G', 'V', 'S', 'D'];
var DAY = 86400000;
function mi(d) { return (d.getDay() + 6) % 7; }
function addD(d, n) { return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); }
function iso(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }
function monday(d) { return addD(d, -mi(d)); }
function same(a, b) { return iso(a) === iso(b); }
function weekLabel(m) { var s = addD(m, 6); return m.getMonth() === s.getMonth() ? (m.getDate() + ' – ' + s.getDate() + ' ' + MO[s.getMonth()]) : (m.getDate() + ' ' + MO[m.getMonth()] + ' – ' + s.getDate() + ' ' + MO[s.getMonth()]); }
function esc(s) { return (typeof escapeHtml === 'function') ? escapeHtml(s) : String(s == null ? '' : s); }
// ── EVENTI reali: dizionario iso-date → [{t, c, time}] ──────────────────
// Popolato da scadenze (api.getUpcomingDeadlines) + attività (recent-activity).
var EVENTS = {};
function pushEvent(dateVal, title, color, time) {
if (!dateVal) return;
var d = new Date(dateVal);
if (isNaN(d.getTime())) return;
var k = iso(d);
(EVENTS[k] = EVENTS[k] || []).push({ t: title || 'Evento', c: color || 'blue', time: time || '' });
}
function evOf(d) { return EVENTS[iso(d)] || []; }
function dots(list) { return list.slice(0, 4).map(function (e) { return '<i class="ev ' + e.c + '"></i>'; }).join(''); }
// ── Popover (porting fedele dal mockup) ─────────────────────────────────
var POP = document.createElement('div'); POP.className = 'cal-pop'; document.body.appendChild(POP);
var popTimer;
function showPop(anchor, html) {
clearTimeout(popTimer);
POP.innerHTML = html; POP.style.display = 'block';
var r = anchor.getBoundingClientRect();
var pw = POP.offsetWidth, ph = POP.offsetHeight;
var left = r.left + r.width / 2 - pw / 2;
left = Math.max(10, Math.min(left, window.innerWidth - pw - 10));
var top = r.top - ph - 10; if (top < 10) top = r.bottom + 10;
POP.style.left = left + 'px'; POP.style.top = top + 'px';
requestAnimationFrame(function () { POP.classList.add('show'); });
}
function hidePop() { POP.classList.remove('show'); popTimer = setTimeout(function () { POP.style.display = 'none'; }, 150); }
function dayPop(d) {
var list = evOf(d);
var head = WDa[mi(d)] + ' ' + d.getDate() + ' ' + MO[d.getMonth()];
var body = list.length
? list.map(function (e) { return '<div class="pe"><span class="ev ' + e.c + '"></span><div>' + (e.time ? '<span class="tt">' + esc(e.time) + '</span>' : '') + '<b>' + esc(e.t) + '</b></div></div>'; }).join('')
: '<div class="empty">Nessun appuntamento.</div>';
return '<h4>' + head + '</h4>' + body;
}
function weekPop(m) {
var items = [];
for (var i = 0; i < 7; i++) { var d = addD(m, i); evOf(d).forEach(function (e) { items.push({ d: d, e: e }); }); }
var body = items.length
? items.slice(0, 6).map(function (o) { return '<div class="pe"><span class="ev ' + o.e.c + '"></span><div><span class="tt">' + WDa[mi(o.d)] + ' ' + o.d.getDate() + '</span><b>' + esc(o.e.t) + '</b></div></div>'; }).join('')
: '<div class="empty">Nessun appuntamento.</div>';
if (items.length > 6) body += '<div class="empty" style="margin-top:6px">+' + (items.length - 6) + ' altri…</div>';
return '<h4>' + weekLabel(m) + '</h4>' + body;
}
function openCal() { window.location.href = 'calendario.html'; }
var dayOffset = 0, weekOffset = 0;
function renderDaily() {
var center = addD(TODAY, dayOffset);
var wrap = document.getElementById('daystrip'); if (!wrap) return; wrap.innerHTML = '';
for (var i = -6; i <= 6; i++) {
(function (d) {
var cell = document.createElement('div');
cell.className = 'daycell' + (mi(d) >= 5 ? ' weekend' : '') + (same(d, TODAY) ? ' today' : '');
cell.innerHTML = '<div class="wd">' + WDa[mi(d)] + '</div><div class="dn">' + d.getDate() + '</div><div class="mo">' + MO[d.getMonth()] + '</div><div class="dots">' + dots(evOf(d)) + '</div>';
cell.addEventListener('mouseenter', function () { showPop(cell, dayPop(d)); });
cell.addEventListener('mouseleave', hidePop);
cell.addEventListener('click', openCal);
wrap.appendChild(cell);
})(addD(center, i));
}
}
function renderWeekly() {
var curMon = monday(TODAY);
var centerMon = addD(curMon, weekOffset * 7);
var wrap = document.getElementById('weekstrip'); if (!wrap) return; wrap.innerHTML = '';
for (var w = -1; w <= 1; w++) {
(function (m) {
var isCur = same(m, curMon);
var diff = Math.round((m - curMon) / (7 * DAY));
var tag = diff === 0 ? 'Settimana in corso' : diff === -1 ? 'Settimana precedente' : diff === 1 ? 'Settimana successiva' : diff < 0 ? (-diff) + ' settimane fa' : 'Tra ' + diff + ' settimane';
var block = document.createElement('div');
block.className = 'weekblock' + (isCur ? ' current' : '');
var minis = '';
for (var i = 0; i < 7; i++) { var d = addD(m, i); minis += '<div class="mini' + (mi(d) >= 5 ? ' weekend' : '') + (same(d, TODAY) ? ' today' : '') + '"><div class="mi-wd">' + WDi[mi(d)] + '</div><div class="mi-dn">' + d.getDate() + '</div><div class="mi-ev">' + dots(evOf(d)) + '</div></div>'; }
block.innerHTML = '<div class="wl"><b>' + weekLabel(m) + '</b><span>' + tag + '</span></div><div class="week7">' + minis + '</div>';
block.addEventListener('mouseenter', function () { showPop(block, weekPop(m)); });
block.addEventListener('mouseleave', hidePop);
block.addEventListener('click', openCal);
wrap.appendChild(block);
})(addD(centerMon, w * 7));
}
}
function wireCalControls() {
var map = [['d-prev', function () { dayOffset--; renderDaily(); }], ['d-next', function () { dayOffset++; renderDaily(); }],
['d-today', function () { dayOffset = 0; renderDaily(); }], ['w-prev', function () { weekOffset--; renderWeekly(); }],
['w-next', function () { weekOffset++; renderWeekly(); }], ['w-today', function () { weekOffset = 0; renderWeekly(); }]];
map.forEach(function (p) { var el = document.getElementById(p[0]); if (el) el.onclick = p[1]; });
}
// ── Stato cella generico (loading/empty/error) ──────────────────────────
function setLoading(id) { var el = document.getElementById(id); if (el) el.innerHTML = '<div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div>'; }
function setEmpty(id, title, sub, icon) { var el = document.getElementById(id); if (el) el.innerHTML = '<div class="v3-empty"><i data-lucide="' + (icon || 'inbox') + '" class="ic"></i><b>' + esc(title) + '</b><span>' + esc(sub || '') + '</span></div>'; refreshIcons(); }
function setError(id, msg) { var el = document.getElementById(id); if (el) el.innerHTML = '<div class="v3-error"><i data-lucide="alert-triangle" class="ic"></i><b>Errore di caricamento</b><span>' + esc(msg || 'Riprova tra poco.') + '</span></div>'; refreshIcons(); }
function refreshIcons() { if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} } }
// ── Mappa categoria → colore badge (coerente col mockup) ────────────────
function catColor(cat) {
var c = String(cat || '').toLowerCase();
if (/incid/.test(c)) return 'red';
if (/formaz|training/.test(c)) return 'green';
if (/govern|riesame|board|direzion/.test(c)) return 'blue';
if (/control|rischi|risk|audit/.test(c)) return 'amber';
return 'gray';
}
// ── 1) Stat cards (3) — dati reali da overview + compliance-score ───────
async function loadStats() {
var elGap = document.getElementById('stat-gap');
var elTrain = document.getElementById('stat-training');
var elRisk = document.getElementById('stat-risk');
try {
var ov = await api.getDashboardOverview();
var d = (ov && ov.success && ov.data) ? ov.data : {};
// Formazione: % completata (overview.training {total, completed})
var tTot = d.training ? (parseInt(d.training.total) || 0) : 0;
var tDone = d.training ? (parseInt(d.training.completed) || 0) : 0;
var tPct = tTot > 0 ? Math.round((tDone / tTot) * 100) : 0;
if (elTrain) {
elTrain.querySelector('.val').textContent = tTot > 0 ? (tPct + '%') : '0%';
elTrain.querySelector('.sub').textContent = tTot > 0 ? (tDone + ' di ' + tTot + ' completati') : 'Nessun corso assegnato';
}
// Rischi valutati: total rischi (overview.risks.total) + % implementazione misure
var riskTotal = d.risks ? (parseInt(d.risks.total) || 0) : 0;
// compliance-score per la percentuale "valutati/implementati"
var pct = 0;
try {
var sc = await api.getComplianceScore();
if (sc && sc.success && sc.data) {
pct = (sc.data.avg_implementation && sc.data.avg_implementation > 0) ? Math.round(sc.data.avg_implementation) : Math.round(sc.data.score || 0);
}
} catch (e) {}
if (elRisk) {
elRisk.querySelector('.val').textContent = pct + '%';
elRisk.querySelector('.sub').textContent = riskTotal > 0 ? ('su ' + riskTotal + ' del registro rischi') : 'Registro rischi vuoto';
var barI = elRisk.querySelector('.bar > i'); if (barI) barI.style.width = Math.max(0, Math.min(100, pct)) + '%';
}
// GAP analysis: numero assessment in corso (overview non sempre lo espone → usa list)
if (elGap) { fillGapCard(elGap); }
} catch (e) {
if (elGap) { elGap.querySelector('.val').textContent = '—'; elGap.querySelector('.sub').textContent = 'Dati non disponibili'; }
if (elTrain) { elTrain.querySelector('.val').textContent = '—'; elTrain.querySelector('.sub').textContent = 'Dati non disponibili'; }
if (elRisk) { elRisk.querySelector('.val').textContent = '—'; elRisk.querySelector('.sub').textContent = 'Dati non disponibili'; }
}
}
async function fillGapCard(elGap) {
try {
var r = await api.listAssessments();
var list = (r && r.success && Array.isArray(r.data)) ? r.data : (r && Array.isArray(r.data && r.data.items) ? r.data.items : []);
if (!Array.isArray(list)) list = [];
var inProgress = list.filter(function (a) { var s = String(a.status || '').toLowerCase(); return s && s !== 'completed' && s !== 'completato'; }).length;
elGap.querySelector('.val').textContent = list.length ? String(inProgress) : '0';
elGap.querySelector('.sub').textContent = list.length ? ('su ' + list.length + ' assessment') : 'Nessun assessment';
} catch (e) {
elGap.querySelector('.val').textContent = '—';
elGap.querySelector('.sub').textContent = 'Dati non disponibili';
}
}
// ── 2) Scadenze (prossimi 10 giorni) — dati reali ───────────────────────
async function loadDeadlines() {
setLoading('sca-list');
try {
var r = await api.getUpcomingDeadlines();
var list = (r && r.success && Array.isArray(r.data)) ? r.data : [];
// alimenta i monitor calendario con eventi reali
list.forEach(function (dl) {
var when = dl.due_date || dl.date || dl.deadline;
pushEvent(when, dl.title || dl.description || 'Scadenza', catColor(dl.category || dl.type), '');
});
renderDaily(); renderWeekly();
var hint = document.getElementById('sca-hint');
if (hint) hint.textContent = list.length + (list.length === 1 ? ' in agenda' : ' in agenda');
if (!list.length) { setEmpty('sca-list', 'Nessuna scadenza imminente', 'Le prossime scadenze appariranno qui.', 'calendar-check'); return; }
var html = list.slice(0, 10).map(function (dl) {
var d = new Date(dl.due_date || dl.date || dl.deadline);
var day = isNaN(d.getTime()) ? '—' : d.getDate();
var mon = isNaN(d.getTime()) ? '' : MO[d.getMonth()].charAt(0).toUpperCase() + MO[d.getMonth()].slice(1);
var cat = dl.category || dl.type || '';
var who = dl.owner || dl.assignee || dl.responsible || '';
return '<div class="sca"><div class="dpill"><b>' + day + '</b><span>' + esc(mon) + '</span></div>' +
'<div class="body"><b>' + esc(dl.title || dl.description || 'Scadenza') + '</b>' +
'<div class="meta">' + (cat ? '<span class="badge-t ' + catColor(cat) + '">' + esc(cat) + '</span>' : '') +
(who ? '<span class="who">' + esc(who) + '</span>' : '') + '</div></div></div>';
}).join('');
document.getElementById('sca-list').innerHTML = html;
} catch (e) {
setError('sca-list');
}
}
// ── 3) Attività aperte — dati reali da recent-activity ──────────────────
async function loadActivities() {
setLoading('act-scroll');
try {
var r = await api.getRecentActivity();
var list = (r && r.success && Array.isArray(r.data)) ? r.data : [];
// alimenta i monitor con eventi reali (data attività)
list.forEach(function (a) {
var when = a.created_at || a.timestamp || a.date;
pushEvent(when, a.description || a.message || a.action || 'Attività', catColor(a.category || a.type || a.entity_type), '');
});
renderDaily(); renderWeekly();
if (!list.length) { setEmpty('act-scroll', 'Nessuna attività recente', 'Le attività appariranno qui.', 'activity'); return; }
var html = list.slice(0, 30).map(function (a) {
var title = a.description || a.message || a.action || 'Attività';
var who = a.user_name || a.actor || a.user || '';
var when = a.created_at || a.timestamp || a.date;
var ago = (typeof timeAgo === 'function' && when) ? timeAgo(when) : '';
var st = a.status || a.entity_type || a.type || '';
var stColor = catColor(st);
return '<div class="act"><div class="body"><b>' + esc(title) + '</b>' +
'<div class="meta">' + (who ? '<span class="who">' + esc(who) + '</span>' : '') +
(ago ? '<span class="who">· ' + esc(ago) + '</span>' : '') + '</div></div>' +
(st ? '<span class="badge-t ' + stColor + '">' + esc(st) + '</span>' : '') + '</div>';
}).join('');
document.getElementById('act-scroll').innerHTML = html;
} catch (e) {
setError('act-scroll');
}
}
// ── 4) Anagrafica aziendale — dati reali da organizations/current ───────
var ENTITY_LABEL = { essential: 'Soggetto essenziale', important: 'Soggetto importante', not_applicable: 'Fuori ambito', voluntary: 'Adesione volontaria' };
function initials(name) {
return String(name || '').trim().split(/\s+/).map(function (w) { return w[0]; }).filter(Boolean).slice(0, 2).join('').toUpperCase() || '—';
}
async function loadAnag() {
var card = document.getElementById('anag-card'); if (!card) return;
try {
var r = await api.getCurrentOrg();
if (!r || !r.success || !r.data) { setEmpty('anag-card', 'Nessuna organizzazione', 'Completa l’onboarding per visualizzare l’anagrafica.', 'building-2'); return; }
var o = r.data;
var nis = ENTITY_LABEL[o.entity_type] || (o.entity_type ? esc(o.entity_type) : 'Da classificare');
var fields = [
['Settore', o.sector || o.nis2_sector || '—'],
['Partita IVA', o.vat_number || '—'],
['Sede legale', [o.city, o.country].filter(Boolean).join(', ') || o.legal_address || '—'],
['Dipendenti', (o.employee_count != null && o.employee_count !== '') ? o.employee_count : '—'],
['Codice fiscale', o.fiscal_code || '—'],
['Classificazione NIS2', nis]
];
var grid = fields.map(function (f) { return '<div class="f"><span>' + esc(f[0]) + '</span><b>' + esc(f[1]) + '</b></div>'; }).join('');
card.innerHTML =
'<div class="anag"><div class="anag-logo">' + esc(initials(o.name)) + '</div>' +
'<div class="info"><div class="nm"><h3>' + esc(o.name || 'Organizzazione') + '</h3>' +
'<span class="badge-nis">' + nis + '</span></div>' +
'<div class="anag-grid">' + grid + '</div></div></div>';
} catch (e) {
setError('anag-card');
}
}
// ── 5) Documenti istituzionali — /api/institutional-docs (ticket #499) ──
// Le 5 voci standard sono SEMPRE presenti (Codice Etico, Mission, Vision,
// Statuto, Piano Sanzionatorio); ogni documento richiede una descrizione.
function canEditDocs() {
var r = (localStorage.getItem('nis2_user_role') || '').toLowerCase();
return r === 'org_admin' || r === 'compliance_manager' || r === 'super_admin';
}
function docRow(item, stdLabel) {
var filled = item && item.description && String(item.description).trim() !== '';
var title = esc((item && item.title) || stdLabel || 'Documento');
var editBtn = canEditDocs()
? '<button class="btn-ghost btn-sm doc-edit" type="button"><i data-lucide="pencil" class="ic"></i></button>' : '';
var delBtn = (canEditDocs() && item && item.id && Number(item.is_standard) !== 1)
? '<button class="btn-ghost btn-sm doc-del" type="button"><i data-lucide="trash-2" class="ic"></i></button>' : '';
var body = filled
? '<span class="doc-desc">' + esc(item.description) + '</span>'
: '<span class="doc-desc doc-empty-desc">Nessuna descrizione — ' + (canEditDocs() ? 'compila la voce.' : 'da compilare.') + '</span>';
var fileLink = (item && item.file_url)
? ' <a class="doc-file" href="' + esc(item.file_url) + '" target="_blank" rel="noopener">documento</a>' : '';
return '<div class="doc-item" data-id="' + esc((item && item.id) || '') +
'" data-key="' + esc((item && item.doc_key) || (stdLabel ? '' : '')) + '">' +
'<div class="doc-main"><b>' + title + fileLink + '</b>' + body + '</div>' +
'<div class="doc-actions">' + editBtn + delBtn + '</div></div>';
}
function stdKeyOf(label, standards) {
for (var i = 0; i < standards.length; i++) if (standards[i].label === label) return standards[i].key;
return null;
}
// Le 5 voci standard DEVONO essere sempre presenti anche se l'API non risponde.
var STD_FALLBACK = [
{ key: 'codice_etico', label: 'Codice Etico' },
{ key: 'mission', label: 'Mission' },
{ key: 'vision', label: 'Vision' },
{ key: 'statuto', label: 'Statuto' },
{ key: 'piano_sanzionatorio', label: 'Piano Sanzionatorio' }
];
function renderDocsCard(docs, standards) {
var card = document.getElementById('doc-card'); if (!card) return;
standards = (standards && standards.length) ? standards : STD_FALLBACK;
docs = docs || [];
var byKey = {}; var customs = [];
docs.forEach(function (d) { if (d.doc_key) byKey[d.doc_key] = d; else customs.push(d); });
var html = '';
standards.forEach(function (s) {
var item = byKey[s.key] || { doc_key: s.key, title: s.label, is_standard: 1 };
html += docRow(item, s.label);
});
customs.forEach(function (d) { html += docRow(d, null); });
if (canEditDocs()) {
html += '<button class="btn-ghost doc-add" type="button" style="margin-top:8px">' +
'<i data-lucide="plus" class="ic"></i>Aggiungi voce</button>';
}
card.innerHTML = '<div class="doc-list">' + html + '</div>';
refreshIcons();
wireDocEvents(card, standards);
}
function loadDocs() {
var card = document.getElementById('doc-card'); if (!card) return;
setLoading('doc-card');
api.request('GET', '/institutional-docs/list').then(function (res) {
var data = (res && res.data) || res || {};
renderDocsCard(data.docs || [], data.standards);
}).catch(function () {
// Anche in caso di errore mostriamo comunque le 5 voci standard.
renderDocsCard([], STD_FALLBACK);
});
}
function docModal(opts, standards) {
var isStd = !!opts.doc_key;
var titleField = isStd
? '<input type="text" id="doc-f-title" class="form-control" value="' + esc(opts.title || '') + '" readonly>'
: '<input type="text" id="doc-f-title" class="form-control" value="' + esc(opts.title || '') + '" maxlength="200" placeholder="Titolo del documento">';
var body =
'<div class="form-group"><label for="doc-f-title">Titolo</label>' + titleField + '</div>' +
'<div class="form-group"><label for="doc-f-desc">Descrizione <span style="color:#c00">*</span></label>' +
'<textarea id="doc-f-desc" class="form-control" rows="4" maxlength="2000" required placeholder="Descrizione del documento (obbligatoria)">' + esc(opts.description || '') + '</textarea></div>' +
'<div class="form-group"><label for="doc-f-file">Link al documento (opzionale)</label>' +
'<input type="url" id="doc-f-file" class="form-control" value="' + esc(opts.file_url || '') + '" placeholder="https://…"></div>';
var footer = '<button class="btn-ghost" type="button" onclick="closeModal()">Annulla</button>' +
'<button class="btn-primary" type="button" id="doc-f-save">Salva</button>';
showModal(opts.id ? 'Modifica documento' : (isStd ? 'Compila: ' + (opts.title || '') : 'Aggiungi documento'), body, { footer: footer });
var saveBtn = document.getElementById('doc-f-save');
saveBtn.addEventListener('click', function () {
var title = document.getElementById('doc-f-title').value.trim();
var desc = document.getElementById('doc-f-desc').value.trim();
var file = document.getElementById('doc-f-file').value.trim();
if (!title) { showNotification('Il titolo è obbligatorio', 'error'); return; }
if (!desc) { showNotification('La descrizione è obbligatoria', 'error'); return; }
saveBtn.disabled = true;
api.request('POST', '/institutional-docs/save', {
id: opts.id || undefined, doc_key: opts.doc_key || undefined,
title: title, description: desc, file_url: file || undefined
}).then(function () {
closeModal(); showNotification('Documento salvato', 'success'); loadDocs();
}).catch(function (e) {
saveBtn.disabled = false;
showNotification((e && e.message) || 'Salvataggio non riuscito', 'error');
});
});
}
function wireDocEvents(card, standards) {
card.querySelectorAll('.doc-edit').forEach(function (btn) {
btn.addEventListener('click', function () {
var el = btn.closest('.doc-item');
docModal({
id: el.getAttribute('data-id') || null,
doc_key: el.getAttribute('data-key') || null,
title: el.querySelector('b') ? el.querySelector('b').childNodes[0].textContent.trim() : '',
description: el.querySelector('.doc-empty-desc') ? '' : (el.querySelector('.doc-desc') ? el.querySelector('.doc-desc').textContent : ''),
file_url: el.querySelector('.doc-file') ? el.querySelector('.doc-file').getAttribute('href') : ''
}, standards);
});
});
card.querySelectorAll('.doc-del').forEach(function (btn) {
btn.addEventListener('click', function () {
var el = btn.closest('.doc-item'); var id = el.getAttribute('data-id');
if (!id || !confirm('Eliminare questa voce?')) return;
api.request('DELETE', '/institutional-docs/' + id).then(function () {
showNotification('Documento eliminato', 'success'); loadDocs();
}).catch(function (e) { showNotification((e && e.message) || 'Eliminazione non riuscita', 'error'); });
});
});
var addBtn = card.querySelector('.doc-add');
if (addBtn) addBtn.addEventListener('click', function () { docModal({}, standards); });
}
// ── Init pubblico (chiamato dall'init pagina dopo checkAuth/loadSidebar) ─
function initDashboard() {
wireCalControls();
renderDaily(); renderWeekly(); // render iniziale (eventi reali arriveranno e ri-renderizzano)
loadStats();
loadDeadlines();
loadActivities();
loadAnag();
loadDocs();
}
window.initDashboard = initDashboard;
})();
+147 -38
View File
@@ -57,6 +57,15 @@ const HelpSystem = (function () {
'La funzione <strong>Genera Policy con AI</strong> permette di creare bozze di policy conformi NIS2 tramite intelligenza artificiale.', 'La funzione <strong>Genera Policy con AI</strong> permette di creare bozze di policy conformi NIS2 tramite intelligenza artificiale.',
'Puoi avviare un nuovo assessment, registrare un incidente o aggiungere un rischio con un solo clic.' 'Puoi avviare un nuovo assessment, registrare un incidente o aggiungere un rischio con un solo clic.'
] ]
},
{
heading: 'Documenti istituzionali',
items: [
'La card <strong>Documenti istituzionali</strong> raccoglie i documenti di governance dell\'organizzazione. Sono <strong>sempre presenti 5 voci standard</strong>: <strong>Codice Etico</strong>, <strong>Mission</strong>, <strong>Vision</strong>, <strong>Statuto</strong> e <strong>Piano Sanzionatorio</strong>.',
'Con <strong>Aggiungi voce</strong> puoi inserire documenti personalizzati oltre ai 5 standard.',
'All\'inserimento o modifica di un documento la <strong>descrizione e\' obbligatoria</strong>: serve a spiegare contenuto e finalita\' del documento.',
'La gestione (aggiungi / modifica / elimina) e\' riservata ai profili <strong>Amministratore</strong> e <strong>Compliance Manager</strong>; tutti i membri dell\'organizzazione possono consultarli.'
]
} }
], ],
references: [ references: [
@@ -599,15 +608,29 @@ const HelpSystem = (function () {
// ─── Risk Management ───────────────────────────────────────── // ─── Risk Management ─────────────────────────────────────────
'risks': { 'risks': {
title: 'Guida - Gestione Rischi', title: 'Guida - Gestione Rischi',
intro: 'Il modulo di Gestione Rischi consente di identificare, valutare e trattare i rischi di sicurezza informatica in conformita\' con l\'art. 24 del D.Lgs. 138/2024 (che recepisce l\'art. 21 della Direttiva (UE) 2022/2555). Include una matrice di rischio interattiva e strumenti per il monitoraggio continuo.', intro: 'In questa pagina i rischi corrispondono ai requisiti del framework di sicurezza applicabili alla tua organizzazione e alla loro valutazione. I requisiti derivano dalle misure di sicurezza di base della Determinazione ACN n. 164179 del 14/4/2025 (Allegato 1 per i soggetti importanti, Allegato 2 per gli essenziali). Ogni voce rappresenta il rischio derivante dal mancato o parziale soddisfacimento del requisito, non il requisito in se\'. Tutto in conformita\' con l\'art. 24 del D.Lgs. 138/2024 (che recepisce l\'art. 21 della Direttiva (UE) 2022/2555).',
sections: [ sections: [
{
heading: 'Come leggere i rischi in questa pagina',
items: [
'La <strong>prima tabella</strong> elenca i rischi <strong>derivati dai requisiti</strong> del framework di sicurezza: ogni voce e\' il <strong>rischio di non conformita\'</strong> legato al mancato o parziale soddisfacimento di un requisito, <strong>non il requisito in se\'</strong>. E\' un elenco in <strong>sola lettura</strong>.',
'Il codice <strong>RSK-‹misura›Com.‹n›</strong> e\' una <strong>codifica interna di NIS2 Agile</strong>; ‹misura› (es. GV.OC-04) e\' il codice della misura del Framework Nazionale richiamato dalla Determinazione ACN 164179/2025. <strong>Non</strong> e\' un codice o numero ufficiale ACN.',
'La tabella ha <strong>12 colonne</strong>: <strong>Codice rischio</strong> (cliccabile, apre la voce in Misure e Requisiti), <strong>Descrizione rischio</strong> (il rischio di default associato al requisito nel framework), e due valutazioni affiancate — <strong>RISCHIO INERENTE</strong> (prima del trattamento) e <strong>RISCHIO RESIDUO</strong> (dopo il trattamento). Ogni valutazione ha Probabilità, Impatto e Valutazione (= Probabilità × Impatto, scala 1–25).',
'I profili <strong>Amministratore</strong>, <strong>Compliance Manager</strong> e <strong>super admin</strong> compilano <strong>direttamente in tabella</strong> Probabilità e Impatto (1–5): la Valutazione si calcola da sola e il salvataggio è automatico. La <strong>Probabilità</strong> è la probabilità che la minaccia sfrutti la carenza del requisito; l\'<strong>Impatto</strong> (scala 1–5, non euro) è la severità della non conformità o del conseguente incidente.',
'La colonna <strong>Alert</strong> compare in base al punteggio inerente: <em>Azione Prioritaria</em> (15–25), <em>Azione Critica</em> (9–12), <em>Azione Necessaria</em> (3–8), <em>Azione Suggerita</em> (1–2). Le soglie sono una <strong>scelta metodologica</strong> dell\'organizzazione (rif. ISO/IEC 27001 §6.1.2), <strong>non un obbligo di legge</strong>. Cliccando l\'Alert si apre l\'elenco delle azioni passate su quel rischio e si può <strong>creare una nuova azione</strong>.',
'La colonna <strong>Esito residuo</strong> segnala se il rischio residuo va <strong>rivalutato</strong> (punteggio 9–25) o è <strong>adeguato</strong> (1–8). <strong>Importante</strong>: il punteggio di rischio residuo <strong>non</strong> determina automaticamente lo <em>stato di conformità</em> del requisito. La <strong>conformità</strong> dipende dall\'attuazione della misura e dalle evidenze e si aggiorna in modo esplicito dal link "stato" nella colonna Rischio Residuo. Sono due piani distinti (punteggio di rischio ≠ conformità al requisito).',
'La tabella elenca <strong>solo i requisiti applicabili alla tua classe</strong> (importante &rarr; Allegato 1, essenziale &rarr; Allegato 2 della Determinazione ACN 164179/2025). Se l\'elenco e\' lungo, il riquadro <strong>scorre verticalmente</strong> con l\'intestazione fissa.',
'In <strong>fondo alla pagina</strong> trovi la sezione <strong>Rischi trasversali (approccio multirischio / all-hazards)</strong> — comprimibile, riservata ai <strong>profili amministrativi</strong> — per registrare le minacce non riconducibili a un singolo requisito (ambiente fisico, catena di fornitura, eventi naturali, errore umano): copre l\'approccio multirischio richiesto dall\'art. 24 D.Lgs. 138/2024 (che recepisce l\'art. 21 §2 della Direttiva (UE) 2022/2555). Si distingue dai «rischi di non conformità» derivati dai requisiti.',
'La tabella dei <strong>Rischi trasversali</strong> ha le <strong>stesse 12 colonne</strong> dei rischi dai requisiti (valutazione inerente e residua, Alert, Esito residuo). In testa alla tabella ci sono i tasti <strong>AI Suggerisci</strong> e <strong>Nuovo Rischio</strong>; ogni riga ha un tasto per <strong>eliminare</strong> il rischio. Sotto la tabella c\'è una <strong>seconda matrice 5×5</strong> dedicata ai soli rischi trasversali, con lo stesso tasto Inerente/Residuo.'
]
},
{ {
heading: 'Matrice di Rischio', heading: 'Matrice di Rischio',
items: [ items: [
'La <strong>matrice Probabilita\' x Impatto</strong> (5x5) classifica automaticamente i rischi in base alla loro gravita\'.', 'La matrice 5×5 (Probabilità × Impatto) e\' uno <strong>strumento di rappresentazione</strong>, non un obbligo normativo: l\'art. 24 D.Lgs. 138/2024 richiede un\'analisi dei rischi proporzionata e con approccio multirischio, senza imporre una metodologia specifica.',
'I livelli di probabilita\' vanno da <em>Molto Bassa</em> a <em>Molto Alta</em>.', 'Le celle hanno <strong>4 fasce di rischio</strong> (dal prodotto Probabilità × Impatto): <strong>1–2 basso</strong> (verde), <strong>3–8 medio</strong> (arancione), <strong>9–12 alto</strong> (rosso tenue), <strong>15–25 critico</strong> (rosso). Sono le stesse soglie della colonna Alert; una <strong>scelta metodologica</strong> dell\'organizzazione (ISO/IEC 27001 §6.1.2), <strong>non un obbligo di legge</strong>.',
'I livelli di impatto vanno da <em>Trascurabile</em> a <em>Critico</em>.', 'Il tasto <strong>Inerente / Residuo</strong> sopra la matrice mostra i giudizi <strong>prima</strong> o <strong>dopo</strong> il trattamento.',
'Il punteggio di rischio risultante determina il colore e la priorita\' del rischio.' 'Ci sono <strong>due matrici</strong>: una per i <strong>rischi dai requisiti</strong> (subito sotto la prima tabella) e una per i <strong>rischi trasversali</strong> (in fondo). La matrice dei requisiti <strong>esclude i "non applicabili"</strong> (per classe o marcati tali).'
] ]
}, },
{ {
@@ -634,30 +657,12 @@ const HelpSystem = (function () {
'Ogni rischio registrato include: descrizione, categoria, beni coinvolti, owner e piano di trattamento.', 'Ogni rischio registrato include: descrizione, categoria, beni coinvolti, owner e piano di trattamento.',
'Lo stato di ogni rischio e\' tracciato nel tempo con lo storico delle modifiche.', 'Lo stato di ogni rischio e\' tracciato nel tempo con lo storico delle modifiche.',
'I rischi possono essere collegati a beni (inventario), incidenti e controlli di compliance.', 'I rischi possono essere collegati a beni (inventario), incidenti e controlli di compliance.',
'La <strong>Matrice Rischi</strong> si aggiorna in tempo reale: eliminando un rischio dalla vista dettaglio, la matrice si ricalcola automaticamente senza richiedere un refresh manuale.' 'La <strong>Matrice Rischi</strong> è <strong>sempre visibile</strong> sotto la tabella e si aggiorna in tempo reale: eliminando un rischio dalla vista dettaglio, la matrice si ricalcola automaticamente senza richiedere un refresh manuale.'
]
},
{
heading: 'Analisi Quantitativa FAIR (vista "Quantitativo")',
items: [
'Affianca alla matrice qualitativa la stima economica della <strong>Perdita Annua Attesa (ALE)</strong> in EUR.',
'Inserisci la frequenza eventi minaccia (TEF min/probabile/max), la vulnerabilita\' (0-1) e la magnitudo di perdita (min/probabile/max).',
'Il motore esegue una simulazione <strong>Monte Carlo</strong> e restituisce i percentili P10/P50/P90 + l\'istogramma della distribuzione delle perdite.',
'Il <strong>Registro Quantitativo</strong> aggrega l\'ALE di portafoglio per prioritizzare gli investimenti di sicurezza sul ritorno economico.'
]
},
{
heading: 'Key Risk Indicators - KRI (vista "KRI")',
items: [
'Definisci indicatori misurabili (es. % endpoint senza MFA, n. vulnerabilita\' critiche aperte) con soglie di warning e critica.',
'Lo stato semaforo (<strong>verde/ambra/rosso</strong>) e\' calcolato automaticamente confrontando il valore corrente con le soglie e la direzione (valori alti o bassi = peggio).',
'La dashboard KRI offre alla Direzione una vista sintetica dell\'esposizione al rischio in tempo reale.'
] ]
} }
], ],
references: [ references: [
'Art. 24 D.Lgs. 138/2024 - Approccio basato sulla valutazione dei rischi (risk-based approach) (recepisce l\'art. 21, par. 1, della Direttiva (UE) 2022/2555)', 'Art. 24 D.Lgs. 138/2024 - Approccio basato sulla valutazione dei rischi (risk-based approach) (recepisce l\'art. 21, par. 1, della Direttiva (UE) 2022/2555)',
'FAIR (Factor Analysis of Information Risk) - The Open Group, standard O-RA per il rischio quantitativo',
'Art. 24 D.Lgs. 138/2024 - Politiche di analisi dei rischi e di sicurezza dei sistemi informatici (recepisce l\'art. 21, par. 2, lett. a, della Direttiva (UE) 2022/2555)', 'Art. 24 D.Lgs. 138/2024 - Politiche di analisi dei rischi e di sicurezza dei sistemi informatici (recepisce l\'art. 21, par. 2, lett. a, della Direttiva (UE) 2022/2555)',
'Art. 24 D.Lgs. 138/2024 - Politiche e procedure per valutare l\'efficacia delle misure (recepisce l\'art. 21, par. 2, lett. f, della Direttiva (UE) 2022/2555)', 'Art. 24 D.Lgs. 138/2024 - Politiche e procedure per valutare l\'efficacia delle misure (recepisce l\'art. 21, par. 2, lett. f, della Direttiva (UE) 2022/2555)',
'Considerando 79 - Proporzionalita\' delle misure rispetto ai rischi' 'Considerando 79 - Proporzionalita\' delle misure rispetto ai rischi'
@@ -928,7 +933,8 @@ const HelpSystem = (function () {
{ {
heading: 'Mappa delle Dipendenze', heading: 'Mappa delle Dipendenze',
items: [ items: [
'La <strong>Mappa Dipendenze</strong> visualizza le relazioni tra i beni.', 'Il pulsante <strong>Mappa Dipendenze</strong> visualizza le relazioni tra i beni (quali beni dipendono da quali).',
'Per popolarla, in <strong>modifica del bene</strong> usa il campo <strong>Dipendenze</strong> e seleziona i beni da cui quel bene dipende; nel dettaglio le dipendenze sono mostrate per nome.',
'Identifica le dipendenze critiche e i singoli punti di guasto (SPOF).', 'Identifica le dipendenze critiche e i singoli punti di guasto (SPOF).',
'Utile per la valutazione dell\'impatto in caso di incidente o indisponibilita\'.' 'Utile per la valutazione dell\'impatto in caso di incidente o indisponibilita\'.'
] ]
@@ -938,7 +944,8 @@ const HelpSystem = (function () {
items: [ items: [
'Tracciamento dello stato del bene: attivo, in manutenzione, dismesso.', 'Tracciamento dello stato del bene: attivo, in manutenzione, dismesso.',
'Registrazione di proprietario, ubicazione, fornitore e contratti di supporto.', 'Registrazione di proprietario, ubicazione, fornitore e contratti di supporto.',
'Alert automatici per beni con supporto in scadenza o fine vita.' 'Alert automatici per beni con supporto in scadenza o fine vita.',
'Per <strong>rimuovere</strong> un bene non piu\' pertinente usa il pulsante <strong>Elimina</strong> (icona cestino, in lista o nel dettaglio del bene): l\'operazione richiede il ruolo org_admin. Da non confondere con lo stato "dismesso", che mantiene lo storico del bene.'
] ]
}, },
{ {
@@ -950,6 +957,23 @@ const HelpSystem = (function () {
'Il campo <code>external_ref</code> abilita la deduplica: re-importare lo stesso ID aggiorna il bene invece di duplicarlo.', 'Il campo <code>external_ref</code> abilita la deduplica: re-importare lo stesso ID aggiorna il bene invece di duplicarlo.',
'Gli stessi dati possono arrivare via connettore automatico (API <code>POST /api/services/assets-ingest</code>, scope ingest:assets).' 'Gli stessi dati possono arrivare via connettore automatico (API <code>POST /api/services/assets-ingest</code>, scope ingest:assets).'
] ]
},
{
heading: 'Esporta (pulsante "Esporta")',
items: [
'Scarica l\'intero inventario in un file <strong>CSV</strong> con le <strong>stesse colonne dell\'importazione</strong>, così puoi salvarne una copia, modificarlo fuori e re-importarlo senza riformattare nulla.',
'Il file usa le colonne riconosciute dall\'import; re-importandolo lo <strong>scoring di rilevanza NIS2</strong> viene ricalcolato e i beni con lo stesso <code>external_ref</code> vengono aggiornati (nessun duplicato).',
'Utile per backup, migrazioni e modifiche massive in foglio di calcolo (il file è in UTF-8, apribile direttamente in Excel).'
]
},
{
heading: 'Ordinare la lista per colonna',
items: [
'Fai <strong>click su un\'intestazione di colonna</strong> (Nome, Tipo, Categoria, Criticità, Rilevanza NIS2, Owner, Stato) per ordinare l\'inventario in base a quella colonna.',
'Un secondo click sulla stessa colonna <strong>inverte l\'ordine</strong> (crescente ⇄ decrescente); la freccia ▲/▼ indica la direzione attiva.',
'Criticità e Rilevanza NIS2 si ordinano per <strong>valore reale</strong> (es. Critico prima di Basso, punteggio alto prima di basso), non in ordine alfabetico.',
'L\'ordinamento agisce su <strong>tutto l\'inventario caricato</strong> e si combina con i filtri Tipo/Criticità/Stato. È accessibile anche da tastiera (Tab fino all\'intestazione, poi Invio o Spazio).'
]
} }
], ],
references: [ references: [
@@ -961,6 +985,44 @@ const HelpSystem = (function () {
] ]
}, },
// ─── Connettori Discovery ────────────────────────────────────
'connettori-discovery': {
title: 'Guida - Connettori Discovery',
intro: 'I connettori di discovery mappano automaticamente la rete o il cloud dell\'organizzazione e popolano l\'Inventario con gli asset rilevati, la rilevanza NIS2, le dipendenze e i flussi di rete (ID.AM-03), riducendo l\'inserimento manuale.',
sections: [
{
heading: 'Più connettori per azienda',
items: [
'Puoi creare <strong>più connettori</strong> per la stessa organizzazione, uno per ciascuna sorgente (es. "Rete sede Roma", "Rete sede Modena", "AWS prod").',
'Ogni connettore ha un <strong>tipo</strong> (rete, AWS, Azure, GCP, CMDB, agente, custom) e una propria <strong>chiave API</strong>.',
'Stato (attivo/in pausa/errore) e storico delle esecuzioni sono nel dettaglio del connettore.'
]
},
{
heading: 'Come funziona',
items: [
'Crea un connettore con "+ Nuovo connettore": ottieni una <strong>chiave API</strong> mostrata una sola volta (copiala subito).',
'Esegui l\'<strong>agente di discovery</strong> (<code>scripts/discovery-agent.mjs</code>) dentro la rete da mappare con quella chiave: scansiona gli host e invia gli asset a NIS2.',
'NIS2 fa <strong>dedup</strong> per indirizzo, calcola il <strong>punteggio di rilevanza NIS2</strong> e aggiorna Inventario + Mappa Dipendenze + flussi di rete.',
'Ogni esecuzione aggiorna "ultimo run" e il numero di asset scoperti.'
]
},
{
heading: 'Sicurezza',
items: [
'La chiave del connettore ha <strong>solo</strong> lo scope <code>ingest:assets</code>: può aggiungere asset/flussi solo alla tua organizzazione, non leggere altri dati.',
'Se una chiave è compromessa usa <strong>Rigenera chiave</strong>: la vecchia smette di funzionare.',
'La gestione dei connettori è riservata agli <strong>amministratori dell\'organizzazione</strong>.'
]
}
],
references: [
'Art. 24 D.Lgs. 138/2024 - Gestione degli attivi e dei sistemi informativi e di rete',
'NIST CSF ID.AM-01/02 - Inventario hardware/software; ID.AM-03 - Flussi di rete',
'Identificazione e classificazione dei sistemi rilevanti - scoring 0-100 (GV.OC-04)'
]
},
// ─── Reports / Audit ───────────────────────────────────────── // ─── Reports / Audit ─────────────────────────────────────────
'reports': { 'reports': {
title: 'Guida - Audit & Report', title: 'Guida - Audit & Report',
@@ -1077,7 +1139,7 @@ const HelpSystem = (function () {
'<strong>Tema</strong>: chiaro, scuro o automatico (segue le impostazioni del sistema operativo).', '<strong>Tema</strong>: chiaro, scuro o automatico (segue le impostazioni del sistema operativo).',
'<strong>Timezone</strong>: usata per visualizzare date e ore. Default Europe/Rome (CET/CEST). Cambia se lavori da un altro fuso.', '<strong>Timezone</strong>: usata per visualizzare date e ore. Default Europe/Rome (CET/CEST). Cambia se lavori da un altro fuso.',
'<strong>Notifiche email</strong>: ricevi via email gli avvisi per incidenti, scadenze e training.', '<strong>Notifiche email</strong>: ricevi via email gli avvisi per incidenti, scadenze e training.',
'<strong>Notifiche in-app</strong>: mostra badge contatori nella sidebar per nuove notifiche.' '<strong>Notifiche in-app</strong>: mostra un badge contatore per le nuove notifiche.'
] ]
}, },
{ {
@@ -1093,7 +1155,7 @@ const HelpSystem = (function () {
{ {
heading: 'Cambio organizzazione (multi-tenant)', heading: 'Cambio organizzazione (multi-tenant)',
items: [ items: [
'Se sei membro di piu\' organizzazioni (es. consulente), nella sidebar appare un <strong>dropdown azienda</strong> in alto.', 'Se sei membro di piu\' organizzazioni (es. consulente), nella barra in alto appare un <strong>selettore azienda</strong>.',
'Click sul dropdown per vedere tutte le organizzazioni a cui hai accesso e quale è quella attualmente attiva (✓).', 'Click sul dropdown per vedere tutte le organizzazioni a cui hai accesso e quale è quella attualmente attiva (✓).',
'Selezionando un\'altra organizzazione il sistema emette un nuovo token con il contesto aggiornato e ricarica la pagina.', 'Selezionando un\'altra organizzazione il sistema emette un nuovo token con il contesto aggiornato e ricarica la pagina.',
'Il cambio è registrato nell\'audit log come <em>context_switch</em>.' 'Il cambio è registrato nell\'audit log come <em>context_switch</em>.'
@@ -1104,7 +1166,7 @@ const HelpSystem = (function () {
items: [ items: [
'Il tab <strong>Branding</strong> consente a uno studio di consulenza di personalizzare l\'interfaccia che vedranno i suoi clienti.', 'Il tab <strong>Branding</strong> consente a uno studio di consulenza di personalizzare l\'interfaccia che vedranno i suoi clienti.',
'<strong>Nome prodotto custom</strong>: sostituisce "NIS2 Agile" con il branding dello studio (es. "Studio Rossi NIS2 Suite").', '<strong>Nome prodotto custom</strong>: sostituisce "NIS2 Agile" con il branding dello studio (es. "Studio Rossi NIS2 Suite").',
'<strong>URL Logo</strong>: link assoluto al logo dello studio (es. https://www.tuosito.it/logo.svg). Verra\' mostrato nella sidebar e nelle pagine di login.', '<strong>URL Logo</strong>: link assoluto al logo dello studio (es. https://www.tuosito.it/logo.svg). Verra\' mostrato nell\'intestazione e nelle pagine di login.',
'<strong>Colori</strong>: il primario sostituisce il blu di default (intestazioni, pulsanti principali); il secondario sostituisce il ciano (badge, accenti).', '<strong>Colori</strong>: il primario sostituisce il blu di default (intestazioni, pulsanti principali); il secondario sostituisce il ciano (badge, accenti).',
'Le modifiche vengono applicate automaticamente a tutti gli utenti del firm al ricaricamento della pagina.' 'Le modifiche vengono applicate automaticamente a tutti gli utenti del firm al ricaricamento della pagina.'
] ]
@@ -1338,28 +1400,35 @@ const HelpSystem = (function () {
// ─── Aziende (consulente) ──────────────────────────────────── // ─── Aziende (consulente) ────────────────────────────────────
'companies': { 'companies': {
title: 'Guida - Aziende Gestite', title: 'Guida - Cruscotto Studio',
intro: 'Il modulo Aziende è dedicato a consulenti e studi: permette di gestire più organizzazioni clienti e passare rapidamente dall\'una all\'altra mantenendo i dati separati.', intro: 'Il Cruscotto Studio è la dashboard dedicata a consulenti e studi: in un\'unica pagina raccoglie tutte le aziende clienti che gestisci con il loro stato di compliance, i rischi e gli incidenti aperti, e l\'elenco aggregato di tutte le scadenze in arrivo o in ritardo.',
sections: [ sections: [
{ {
heading: 'Gestione del portafoglio clienti', heading: 'Panoramica dello studio',
items: [ items: [
'Visualizzi l\'elenco delle <strong>aziende che gestisci</strong> con il relativo livello di compliance.', 'La <strong>barra KPI</strong> in alto riepiloga il portafoglio: numero di aziende, compliance media, scadenze in ritardo (in rosso) e scadenze in arrivo.',
'Puoi <strong>aggiungere</strong> una nuova azienda cliente e accedere ai suoi moduli.', 'Ogni <strong>scheda azienda</strong> mostra settore, classificazione NIS2 (essenziale/importante/volontaria), barra di compliance, rischi e incidenti aperti e un badge rosso se ci sono scadenze scadute.',
'Il pulsante <strong>"Entra"</strong> imposta l\'organizzazione attiva: tutti i moduli (rischi, incidenti, policy, fornitori...) mostrano i dati di quell\'azienda.' 'Il <strong>clic su una scheda</strong> imposta l\'organizzazione attiva e apre la sua dashboard: tutti i moduli (rischi, incidenti, policy, fornitori...) mostrano poi i dati di quell\'azienda.'
]
},
{
heading: 'Scadenze generali (tutte le aziende)',
items: [
'La sezione in fondo elenca <strong>tutte le scadenze</strong> del portafoglio ordinate per data: per ciascuna vedi azienda, tipo di adempimento (Early Warning, Notifica CSIRT, Report finale, Revisione policy, Trattamento rischio, Formazione), data e stato.',
'Le scadenze <strong>scadute</strong> sono evidenziate in rosso; le altre indicano tra quanti giorni cadono.'
] ]
}, },
{ {
heading: 'Separazione dei dati', heading: 'Separazione dei dati',
items: [ items: [
'Ogni azienda è un\'organizzazione isolata: i dati non si mescolano mai tra clienti diversi (multi-tenancy).', 'Ogni azienda è un\'organizzazione isolata: i dati non si mescolano mai tra clienti diversi (multi-tenancy).',
'Il cambio di contesto è tracciato a fini di audit.' 'Il cambio di contesto (ingresso in un\'azienda) è tracciato a fini di audit.'
] ]
} }
], ],
references: [ references: [
'Architettura multi-tenant: ogni organizzazione ha dati isolati', 'Architettura multi-tenant: ogni organizzazione ha dati isolati',
'Ruolo consulente con accesso a più organizzazioni' 'Ruolo consulente/super admin con accesso a più organizzazioni'
] ]
}, },
@@ -1490,6 +1559,8 @@ const HelpSystem = (function () {
'training': 'training', 'training': 'training',
'assets.html': 'assets', 'assets.html': 'assets',
'assets': 'assets', 'assets': 'assets',
'connettori-discovery.html': 'connettori-discovery',
'connettori-discovery': 'connettori-discovery',
'reports.html': 'reports', 'reports.html': 'reports',
'audit.html': 'reports', 'audit.html': 'reports',
'reports': 'reports', 'reports': 'reports',
@@ -1859,6 +1930,42 @@ const HelpSystem = (function () {
'</span>'; '</span>';
} }
/**
* Versione testo-piano del contenuto di help della pagina (per ARIA).
* Stessa fonte del pannello "?" → l'AI resta ALLINEATA all'help mostrato all'utente.
* @param {string} [pageId] - se assente, rileva la pagina corrente.
* @param {number} [maxLen] - cap caratteri (default 2200).
* @returns {string} testo piano, '' se la pagina non ha help.
*/
function _stripHtml(s) {
return String(s || '')
.replace(/<[^>]+>/g, '')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
.replace(/&ge;/g, '≥').replace(/&le;/g, '≤').replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ').trim();
}
function getContextText(pageId, maxLen) {
if (!pageId) pageId = _detectPage();
var d = pageId && _helpContent[pageId];
if (!d) return '';
var out = [];
if (d.title) out.push(_stripHtml(d.title).replace(/^Guida\s*-\s*/i, '').trim());
if (d.intro) out.push(_stripHtml(d.intro));
(d.sections || []).forEach(function (s) {
var items = (s.items || []).map(_stripHtml).join(' ');
out.push(('• ' + (s.heading || '') + ': ' + items).trim());
});
if (d.references && d.references.length) {
out.push('Riferimenti: ' + d.references.map(_stripHtml).join('; '));
}
var txt = out.join('\n');
maxLen = maxLen || 2200;
if (txt.length > maxLen) txt = txt.slice(0, maxLen) + '…';
return txt;
}
/** pageId canonico della pagina corrente (o '' se non mappata). */
function detectPageId() { return _detectPage() || ''; }
/** /**
* Inizializza il sistema di help: inietta gli stili e aggiunge il pulsante help * Inizializza il sistema di help: inietta gli stili e aggiunge il pulsante help
* nell'header della pagina corrente (se applicabile). * nell'header della pagina corrente (se applicabile).
@@ -1883,7 +1990,9 @@ const HelpSystem = (function () {
showHelp: showHelp, showHelp: showHelp,
addHelpButton: addHelpButton, addHelpButton: addHelpButton,
inlineHelp: inlineHelp, inlineHelp: inlineHelp,
getAvailablePages: getAvailablePages getAvailablePages: getAvailablePages,
getContextText: getContextText, // testo help della pagina per ARIA (allineamento)
detectPageId: detectPageId // pageId canonico corrente
}; };
})(); })();
+137
View File
@@ -96,6 +96,39 @@ const I18n = (function () {
'nav.settings': { it: 'Impostazioni', en: 'Settings' }, 'nav.settings': { it: 'Impostazioni', en: 'Settings' },
'nav.help': { it: 'Guida', en: 'Help' }, 'nav.help': { it: 'Guida', en: 'Help' },
'nav.logout': { it: 'Esci', en: 'Logout' }, 'nav.logout': { it: 'Esci', en: 'Logout' },
// ── Voci nav UI V3 (top-nav mega-menu) ──
'nav.register_incident': { it: 'Registra incidente', en: 'Report incident' },
'nav.connectors': { it: 'Connettori Discovery', en: 'Discovery Connectors' },
'nav.whistleblowing': { it: 'Segnalazioni ricevute', en: 'Reports received' },
'nav.normative': { it: 'Normative', en: 'Regulations' },
'nav.cross_analysis': { it: 'AI Cross-Analysis', en: 'AI Cross-Analysis' },
'nav.kb': { it: 'Knowledge Base', en: 'Knowledge Base' },
'nav.architecture': { it: 'Architettura', en: 'Architecture' },
'nav.simulate': { it: 'Simulazione Demo', en: 'Demo Simulation' },
'nav.integrations': { it: 'Integrazioni Esterne', en: 'External Integrations' },
'nav.workflow': { it: 'Compliance Journey', en: 'Compliance Journey' },
'nav.audit_acn': { it: 'Audit ACN', en: 'ACN Audit' },
'nav.studio': { it: 'Le mie aziende', en: 'My companies' },
// ── Etichette gruppi mega-menu V3 ──
'nav.grp_stakeholders': { it: 'Stakeholders', en: 'Stakeholders' },
'nav.grp_internal': { it: 'Struttura interna', en: 'Internal structure' },
'nav.grp_risks': { it: 'Rischi', en: 'Risks' },
'nav.grp_monitoring': { it: 'Monitoraggio', en: 'Monitoring' },
'nav.grp_lexiso': { it: 'Lex e ISO', en: 'Law & ISO' },
'nav.grp_ai': { it: 'AI', en: 'AI' },
'nav.grp_guide': { it: 'Guida', en: 'Guide' },
// ── Cruscotto Studio (dashboard consulente) ──
'companies.title': { it: 'Cruscotto Studio', en: 'Studio Dashboard' },
'companies.subtitle': { it: 'Portafoglio clienti, compliance e scadenze in un colpo d\'occhio', en: 'Client portfolio, compliance and deadlines at a glance' },
'companies.kpi_total': { it: 'Aziende gestite', en: 'Managed companies' },
'companies.kpi_avg': { it: 'Compliance media', en: 'Average compliance' },
'companies.kpi_overdue': { it: 'Scadenze in ritardo', en: 'Overdue deadlines' },
'companies.kpi_upcoming': { it: 'Scadenze in arrivo', en: 'Upcoming deadlines' },
'companies.deadlines_title': { it: 'Scadenze generali (tutte le aziende)', en: 'General deadlines (all companies)' },
'companies.deadlines_empty': { it: 'Nessuna scadenza nei prossimi 30 giorni', en: 'No deadlines in the next 30 days' },
// ── Titoli pagina aggiunti dalla flotta V3 (mancavano → si vedeva la chiave) ──
'mr.title': { it: 'Misure e Requisiti', en: 'Measures & Requirements' },
'normative.title': { it: 'Aggiornamenti Normativi', en: 'Regulatory Updates' },
// ── Dashboard ────────────────────────────────── // ── Dashboard ──────────────────────────────────
'dashboard.title': { it: 'Dashboard', en: 'Dashboard' }, 'dashboard.title': { it: 'Dashboard', en: 'Dashboard' },
@@ -136,6 +169,27 @@ const I18n = (function () {
'risks.accept': { it: 'Accettare', en: 'Accept' }, 'risks.accept': { it: 'Accettare', en: 'Accept' },
'risks.transfer': { it: 'Trasferire', en: 'Transfer' }, 'risks.transfer': { it: 'Trasferire', en: 'Transfer' },
'risks.avoid': { it: 'Evitare', en: 'Avoid' }, 'risks.avoid': { it: 'Evitare', en: 'Avoid' },
'risks.derived_heading': { it: 'Rischi dai requisiti del framework di sicurezza', en: 'Risks from security framework requirements' },
'risks.derived_code_legend': { it: 'RSK-‹misura›Com.‹n› è una codifica interna di NIS2 Agile; ‹misura› (es. GV.OC-04) è il codice della misura del Framework Nazionale richiamato dalla Determinazione ACN 164179/2025. Elenco in sola lettura.', en: 'RSK-‹measure›Com.‹n› is an internal NIS2 Agile code; ‹measure› (e.g. GV.OC-04) is the National Framework measure code referenced by ACN Determination 164179/2025. Read-only list.' },
'risks.col_code': { it: 'Codice rischio', en: 'Risk code' },
'risks.col_risk': { it: 'Descrizione rischio', en: 'Risk description' },
'risks.grp_inherent': { it: 'Rischio Inerente', en: 'Inherent Risk' },
'risks.grp_residual': { it: 'Rischio Residuo', en: 'Residual Risk' },
'risks.col_alert': { it: 'Alert', en: 'Alert' },
'risks.col_inherent': { it: 'Rischio Inerente', en: 'Inherent risk' },
'risks.col_res_outcome': { it: 'Esito residuo', en: 'Residual outcome' },
'risks.col_residual': { it: 'Rischio Residuo', en: 'Residual risk' },
'risks.col_measure': { it: 'Misura', en: 'Measure' },
'risks.col_conformity': { it: 'Conformità', en: 'Conformity' },
'risks.col_evaluation': { it: 'Valutazione', en: 'Evaluation' },
'risks.custom_heading': { it: 'Rischi trasversali (approccio multirischio / all-hazards) — art. 24 D.Lgs. 138/2024', en: 'Cross-cutting risks (multi-risk / all-hazards approach) — art. 24 Legislative Decree 138/2024' },
'risks.custom_help': { it: 'Qui si registrano le minacce non riconducibili a un singolo requisito (ambiente fisico, catena di fornitura, eventi naturali, errore umano), come richiesto dall\'approccio multirischio/all-hazards (art. 24 D.Lgs. 138/2024, che recepisce l\'art. 21 §2 della Direttiva (UE) 2022/2555). Si distinguono dai «rischi di non conformità» della tabella qui sopra, che misurano il mancato o parziale soddisfacimento dei singoli requisiti.', en: 'Register here the threats not attributable to a single requirement (physical environment, supply chain, natural events, human error), as required by the multi-risk / all-hazards approach (art. 24 Legislative Decree 138/2024, transposing art. 21(2) of Directive (EU) 2022/2555). They differ from the “non-compliance risks” in the table above, which measure the missed or partial fulfilment of individual requirements.' },
'risks.matrix_caption': { it: 'La matrice 5×5 è uno strumento di rappresentazione, non un obbligo normativo (art. 24 D.Lgs. 138/2024).', en: 'The 5×5 matrix is a representation tool, not a regulatory obligation (art. 24 Legislative Decree 138/2024).' },
'risks.matrix_subtitle': { it: 'Rischi da requisiti (valutati) + aggiuntivi — esclusi i non applicabili', en: 'Requirement-derived risks (evaluated) + additional ones — non-applicable excluded' },
'risks.col_probability': { it: 'Probabilità', en: 'Likelihood' },
'risks.col_impact': { it: 'Impatto', en: 'Impact' },
'risks.col_risk_level': { it: 'Rischio (P×I)', en: 'Risk (L×I)' },
'risks.col_actions': { it: 'Azioni', en: 'Actions' },
// ── Incidenti ────────────────────────────────── // ── Incidenti ──────────────────────────────────
'incidents.title': { it: 'Gestione Incidenti', en: 'Incident Management' }, 'incidents.title': { it: 'Gestione Incidenti', en: 'Incident Management' },
@@ -491,6 +545,89 @@ const I18n = (function () {
'sp.import_too_many': { it: 'Troppi record (max 1000).', en: 'Too many records (max 1000).' }, 'sp.import_too_many': { it: 'Troppi record (max 1000).', en: 'Too many records (max 1000).' },
'sp.import_done': { it: 'Completato: creati {created}, aggiornati {updated}, saltati {skipped}.', en: 'Done: {created} created, {updated} updated, {skipped} skipped.' }, 'sp.import_done': { it: 'Completato: creati {created}, aggiornati {updated}, saltati {skipped}.', en: 'Done: {created} created, {updated} updated, {skipped} skipped.' },
'sp.conn_error': { it: 'Errore di connessione.', en: 'Connection error.' }, 'sp.conn_error': { it: 'Errore di connessione.', en: 'Connection error.' },
// ── Supply Chain / Fornitori — i18n estesa pagina interna (2026-06-21) ──
'sp.stat_total': { it: 'Totale Fornitori', en: 'Total Suppliers' },
'sp.stat_critical': { it: 'Critici Non Conformi', en: 'Critical Non-Compliant' },
'sp.stat_expiring': { it: 'Contratti in Scadenza', en: 'Expiring Contracts' },
'sp.stat_avg_score': { it: 'Score Rischio Medio', en: 'Average Risk Score' },
'sp.crit_distribution': { it: 'Distribuzione Criticità', en: 'Criticality Distribution' },
'sp.crit_critical': { it: 'Critico', en: 'Critical' },
'sp.crit_high': { it: 'Alto', en: 'High' },
'sp.crit_medium': { it: 'Medio', en: 'Medium' },
'sp.crit_low': { it: 'Basso', en: 'Low' },
'sp.filter_all_crit': { it: 'Tutte le Criticità', en: 'All Criticalities' },
'sp.filter_all_status': { it: 'Tutti gli Stati', en: 'All Statuses' },
'sp.status_active': { it: 'Attivo', en: 'Active' },
'sp.status_under_review':{ it: 'In Revisione', en: 'Under Review' },
'sp.status_suspended': { it: 'Sospeso', en: 'Suspended' },
'sp.status_terminated': { it: 'Terminato', en: 'Terminated' },
'sp.loading_suppliers': { it: 'Caricamento fornitori...', en: 'Loading suppliers...' },
'sp.loading': { it: 'Caricamento...', en: 'Loading...' },
'sp.no_suppliers': { it: 'Nessun fornitore trovato', en: 'No suppliers found' },
'sp.no_suppliers_hint': { it: 'Aggiungi il tuo primo fornitore per iniziare il monitoraggio della supply chain.', en: 'Add your first supplier to start monitoring the supply chain.' },
'sp.col_name': { it: 'Nome', en: 'Name' },
'sp.col_service_type': { it: 'Tipo Servizio', en: 'Service Type' },
'sp.col_criticality': { it: 'Criticità', en: 'Criticality' },
'sp.col_risk_score': { it: 'Score Rischio', en: 'Risk Score' },
'sp.col_status': { it: 'Stato', en: 'Status' },
'sp.col_last_assessment':{ it: 'Ultima Valutazione', en: 'Last Assessment' },
'sp.col_actions': { it: 'Azioni', en: 'Actions' },
'sp.back_to_list': { it: "Torna all'elenco", en: 'Back to list' },
'sp.compliant': { it: 'Conforme', en: 'Compliant' },
'sp.non_compliant': { it: 'Non Conforme', en: 'Non-Compliant' },
'sp.assess_supplier': { it: 'Valuta Fornitore', en: 'Assess Supplier' },
'sp.send_questionnaire': { it: 'Invia questionario', en: 'Send questionnaire' },
'sp.edit': { it: 'Modifica', en: 'Edit' },
'sp.delete': { it: 'Elimina', en: 'Delete' },
'sp.info_title': { it: 'Informazioni Fornitore', en: 'Supplier Information' },
'sp.field_name': { it: 'Nome', en: 'Name' },
'sp.field_vat': { it: 'Partita IVA', en: 'VAT Number' },
'sp.field_contact_name': { it: 'Referente', en: 'Contact Person' },
'sp.field_email': { it: 'Email', en: 'Email' },
'sp.field_service_type': { it: 'Tipo Servizio', en: 'Service Type' },
'sp.field_contract_start':{ it: 'Inizio Contratto', en: 'Contract Start' },
'sp.field_contract_expiry':{ it: 'Scadenza Contratto', en: 'Contract Expiry' },
'sp.service_desc_title': { it: 'Descrizione Servizio', en: 'Service Description' },
'sp.notes_title': { it: 'Note', en: 'Notes' },
'sp.out_of_100': { it: 'su 100', en: 'out of 100' },
'sp.sec_req_met': { it: 'Requisiti di sicurezza soddisfatti', en: 'Security requirements met' },
'sp.sec_req_not_met': { it: 'Requisiti di sicurezza non soddisfatti', en: 'Security requirements not met' },
'sp.no_assessment_done': { it: 'Nessuna valutazione effettuata', en: 'No assessment performed' },
'sp.assessment_label': { it: 'Valutazione', en: 'Assessment' },
'sp.last_assessment': { it: 'Ultima Valutazione', en: 'Last Assessment' },
'sp.next_assessment': { it: 'Prossima Valutazione', en: 'Next Assessment' },
'sp.sec_requirements': { it: 'Requisiti Sicurezza', en: 'Security Requirements' },
'sp.met': { it: 'Soddisfatti', en: 'Met' },
'sp.not_met': { it: 'Non Soddisfatti', en: 'Not Met' },
'sp.created_at': { it: 'Creato il', en: 'Created on' },
'sp.edit_supplier': { it: 'Modifica Fornitore', en: 'Edit Supplier' },
'sp.new_supplier_modal': { it: 'Nuovo Fornitore', en: 'New Supplier' },
'sp.field_supplier_name':{ it: 'Nome Fornitore', en: 'Supplier Name' },
'sp.field_contact_email':{ it: 'Email Contatto', en: 'Contact Email' },
'sp.ph_company_name': { it: "Nome dell'azienda", en: 'Company name' },
'sp.ph_full_name': { it: 'Nome e cognome', en: 'Full name' },
'sp.ph_service_type': { it: 'es. Cloud Hosting, Sviluppo SW, Consulenza', en: 'e.g. Cloud Hosting, SW Development, Consulting' },
'sp.ph_service_desc': { it: 'Descrivi il servizio fornito...', en: 'Describe the service provided...' },
'sp.ph_notes': { it: 'Note aggiuntive...', en: 'Additional notes...' },
'sp.cancel': { it: 'Annulla', en: 'Cancel' },
'sp.save_changes': { it: 'Salva Modifiche', en: 'Save Changes' },
'sp.add_supplier': { it: 'Aggiungi Fornitore', en: 'Add Supplier' },
'sp.assess_modal_title': { it: 'Valutazione Fornitore', en: 'Supplier Assessment' },
'sp.assess_intro': { it: "Rispondi alle domande per valutare il livello di sicurezza del fornitore. Il punteggio di rischio verrà calcolato automaticamente.", en: 'Answer the questions to assess the supplier security level. The risk score will be calculated automatically.' },
'sp.answer_yes': { it: 'Sì', en: 'Yes' },
'sp.answer_partial': { it: 'Parziale', en: 'Partial' },
'sp.answer_no': { it: 'No', en: 'No' },
'sp.calculated_risk_score':{ it: 'Score Rischio Calcolato', en: 'Calculated Risk Score' },
'sp.save_assessment': { it: 'Salva Valutazione', en: 'Save Assessment' },
'sp.cat_empty': { it: 'Nessuna categoria.', en: 'No categories.' },
'sp.cat_new_title': { it: 'Nuova categoria', en: 'New category' },
'sp.cat_edit': { it: 'Modifica categoria', en: 'Edit category' },
'sp.ph_cat_name': { it: 'es. Cloud Provider', en: 'e.g. Cloud Provider' },
'sp.ph_cat_desc': { it: 'Descrizione opzionale...', en: 'Optional description...' },
'sp.save': { it: 'Salva', en: 'Save' },
'sp.create': { it: 'Crea', en: 'Create' },
'sp.close': { it: 'Chiudi', en: 'Close' },
'sp.tpl_back_plain': { it: 'Torna ai template', en: 'Back to templates' },
}); });
return { init, getLang, setLang, t, applyTranslations, addTranslations }; return { init, getLang, setLang, t, applyTranslations, addTranslations };
+8 -3
View File
@@ -61,10 +61,15 @@ function orgRender() {
el('org-stat-vacant').textContent = vacant; el('org-stat-vacant').textContent = vacant;
stats.style.display = 'flex'; stats.style.display = 'flex';
tree.innerHTML = ORG.tree.map(orgNodeHtml).join(''); tree.innerHTML = ORG.tree.map(n => orgNodeHtml(n, new Set())).join('');
} }
function orgNodeHtml(node) { function orgNodeHtml(node, seen) {
// Guardia anti-ciclo: se un id ricompare nella catena il render si fermerebbe in loop
// infinito (il "blocco del sito" segnalato). Rompiamo il ciclo invece di ricorrere.
seen = seen || new Set();
if (!node || seen.has(node.id)) return '';
seen.add(node.id);
const gov = node.is_governance_body; const gov = node.is_governance_body;
const holder = node.holder_name const holder = node.holder_name
? '<div class="org-holder">👤 ' + esc(node.holder_name) + '</div>' ? '<div class="org-holder">👤 ' + esc(node.holder_name) + '</div>'
@@ -72,7 +77,7 @@ function orgNodeHtml(node) {
const desc = node.description ? '<div class="org-desc">' + esc(node.description) + '</div>' : ''; const desc = node.description ? '<div class="org-desc">' + esc(node.description) + '</div>' : '';
const govBadge = gov ? '<span class="org-badge gov" title="Organo di amministrazione/direttivo (art. 23 D.Lgs. 138/2024)">Governance · Art. 23</span>' : ''; const govBadge = gov ? '<span class="org-badge gov" title="Organo di amministrazione/direttivo (art. 23 D.Lgs. 138/2024)">Governance · Art. 23</span>' : '';
const children = (node.children && node.children.length) const children = (node.children && node.children.length)
? '<div class="org-children">' + node.children.map(orgNodeHtml).join('') + '</div>' ? '<div class="org-children">' + node.children.map(c => orgNodeHtml(c, seen)).join('') + '</div>'
: ''; : '';
return '' + return '' +
+354
View File
@@ -0,0 +1,354 @@
/* ═══════════════════════════════════════════════════════════════════════════
* topnav-v3.js — Chrome V3 (appbar + mega-menu) data-driven dalla mappa IA (§6).
* ───────────────────────────────────────────────────────────────────────────
* Sostituisce la sidebar V2 (common-bi.js) con la top-nav del mockup
* "docs/mockup Ui/index.html". REGOLE:
* - SOLO UI. Riusa gli helper di common.js: _loadUserInfo, _loadVersionFooter,
* _loadConsultantOrgSwitcher (via _loadUserInfo), _loadFirmBranding,
* _renderImpersonateBanner, switchLang, _getSavedLang, api.logout.
* - Stessi ID DOM degli helper V2 (#sidebar-user-name/-avatar/-role,
* #sidebar-org-switcher, #sidebar-version, .lang-btn) → gli helper esistenti
* funzionano invariati senza toccare api.js.
* - Espone window.loadSidebar = loadTopnavV3 → l'init delle pagine resta invariato.
* - I 3 FAB reali (campana notifiche + bug-reporter + ARIA) restano quelli di
* common.js (qui NON si reimplementano: si applica solo lo stile .fab-stack/.fab
* al trittico già montato, per fedeltà al mockup).
* - aria-current sulla voce/gruppo attivo (da location.pathname).
* - Icone Lucide via <i data-lucide> + lucide.createIcons() (chiamato dall'init pagina).
* ═══════════════════════════════════════════════════════════════════════════ */
(function () {
'use strict';
// ── Mappa IA (§6 dello spec). 9 gruppi → voci/href reali. ───────────────
// top-level: voce diretta (href) oppure gruppo con mega-menu (cols).
var NAV_GROUPS = [
{ label: 'Dashboard', i18n: 'nav.dashboard', icon: 'layout-dashboard', href: 'dashboard.html' },
// Voce riservata a consulenti/super_admin — gate applicato in loadTopnavV3().
{ label: 'Le mie aziende', i18n: 'nav.studio', icon: 'briefcase', href: 'companies.html', studioOnly: true },
{ label: 'Calendario', i18n: 'nav.calendar', icon: 'calendar', href: 'calendario.html' },
{
label: 'Stakeholders', icon: 'users',
cols: [{ head: 'RELAZIONI', links: [
{ b: 'Stakeholders', s: 'Soggetti interni ed esterni rilevanti', href: 'stakeholders.html', ic: 'handshake' },
{ b: 'Supply-Chain', s: 'Rischio fornitori e terze parti', href: 'supply-chain.html', ic: 'link' },
{ b: 'Attività su Stakeholders', s: 'Interazioni, richieste e impegni', href: 'stakeholder-activities.html', ic: 'activity' }
]}]
},
{
label: 'Struttura interna', icon: 'building-2',
cols: [{ head: 'RUOLI E COMPETENZE', links: [
{ b: 'Organigramma', s: 'Ruoli, deleghe e responsabilità', href: 'organigramma.html', ic: 'network' },
{ b: 'Competenze', s: 'Skill matrix e copertura dei ruoli', href: 'competenze.html', ic: 'award' },
{ b: 'GAP Analysis', s: 'Scostamento tra stato attuale e atteso', href: 'assessment.html', ic: 'git-compare' },
{ b: 'Matrice RACI', s: 'Responsabilità per attività (R/A/C/I)', href: 'raci.html', ic: 'grid-3x3' },
{ b: 'Formazione', s: 'Piani formativi e completamento', href: 'training.html', ic: 'graduation-cap' }
]}]
},
{
label: 'Rischi', icon: 'shield-alert',
cols: [{ head: 'GESTIONE RISCHIO', links: [
{ b: 'Mappa dei rischi', s: 'Registro rischi e piani di trattamento', href: 'risks.html', ic: 'map' },
{ b: 'Inventario', s: 'Asset e perimetro tecnico', href: 'assets.html', ic: 'boxes' },
{ b: 'Policy', s: 'Politiche approvate e in revisione', href: 'policies.html', ic: 'file-text' },
{ b: 'Connettori Discovery', s: 'Scansione rete/cloud → inventario', href: 'connettori-discovery.html', ic: 'plug-zap' }
]}]
},
{
label: 'Monitoraggio', icon: 'radar',
cols: [{ head: 'VIGILANZA E AUDIT', links: [
{ b: 'Segnalazioni ricevute', s: 'Whistleblowing e segnalazioni interne', href: 'whistleblowing.html', ic: 'megaphone' },
{ b: 'Audit interni', s: 'Programma e esiti degli audit', href: 'internal-audits.html', ic: 'clipboard-check' },
{ b: 'Audit ACN', s: 'Verifiche e ispezioni dell’autorità', href: 'reports.html', ic: 'file-check' },
{ b: 'Controlli periodici', s: 'Verifiche ricorrenti e check-list', href: 'controlli-periodici.html', ic: 'list-checks' },
{ b: 'Riesame di Direzione', s: 'Esiti del riesame §9.3', href: 'management-review.html', ic: 'gauge' },
{ b: 'Scadenziario', s: 'Revisioni e adempimenti ricorrenti', href: 'review-schedule.html', ic: 'calendar-clock' }
]}]
},
{
label: 'Lex e ISO', icon: 'scale',
cols: [{ head: 'NORMATIVA E ISO', links: [
{ b: 'Normative', s: 'Direttiva, decreto e riferimenti applicabili', href: 'normative.html', ic: 'scroll-text' },
{ b: 'Modello SGSI (ISO 27001)', s: 'Sistema di gestione sicurezza informazioni', href: 'isms.html', ic: 'shield-check' },
{ b: 'Misure e requisiti ACN', s: 'Misure tecniche e organizzative Art. 21', href: 'misure-requisiti.html', ic: 'list-checks' }
]}]
},
{
label: 'AI', icon: 'sparkles',
cols: [{ head: 'ASSISTENTE', links: [
{ b: 'AI Cross-Analysis', s: 'Analisi trasversale di rischi e misure', href: 'cross-analysis.html', ic: 'sparkles' },
{ b: 'Knowledge Base', s: 'Procedure, linee guida e riferimenti', href: 'kb.html', ic: 'book-open' }
]}]
},
{
label: 'Guida', icon: 'life-buoy',
cols: [
{ head: 'GUIDA', links: [
{ b: 'Guida all’uso', s: 'Manuale e istruzioni della piattaforma', href: 'guida.html', ic: 'life-buoy' },
{ b: 'Compliance Journey', s: 'Percorso guidato di conformità', href: 'workflow.html', ic: 'route' },
{ b: 'Integrazioni Esterne', s: 'Connettori e servizi collegati', href: 'integrazioniext.html', ic: 'plug' }
]},
{ head: 'SISTEMA', links: [
{ b: 'Impostazioni', s: 'Configurazione del workspace', href: 'settings.html', ic: 'settings' },
{ b: 'Architettura', s: 'Modello dati e struttura del sistema', href: 'architecture.html', ic: 'layers' },
{ b: 'Simulazione Demo', s: 'Scenari guidati di prova', href: 'simulate.html', ic: 'play-circle' }
]}
]
}
];
// ── i18n: traduzione voci nav (IT/EN) come nel V2. Chiave per href/gruppo. ──
var I18N_BY_HREF = {
'dashboard.html': 'nav.dashboard', 'calendario.html': 'nav.calendar',
'stakeholders.html': 'nav.stakeholders', 'supply-chain.html': 'nav.supply_chain', 'stakeholder-activities.html': 'nav.stk_activities',
'organigramma.html': 'nav.org_chart', 'competenze.html': 'nav.competences', 'assessment.html': 'nav.gap_analysis', 'raci.html': 'nav.raci', 'training.html': 'nav.training',
'risks.html': 'nav.risks', 'assets.html': 'nav.assets', 'policies.html': 'nav.policies', 'connettori-discovery.html': 'nav.connectors',
'whistleblowing.html': 'nav.whistleblowing', 'internal-audits.html': 'nav.internal_audits', 'reports.html': 'nav.audit_acn', 'controlli-periodici.html': 'nav.periodic_controls', 'management-review.html': 'nav.management_review', 'review-schedule.html': 'nav.review_schedule',
'normative.html': 'nav.normative', 'isms.html': 'nav.isms', 'misure-requisiti.html': 'nav.framework',
'cross-analysis.html': 'nav.cross_analysis', 'kb.html': 'nav.kb',
'guida.html': 'nav.help', 'workflow.html': 'nav.workflow', 'integrazioniext.html': 'nav.integrations', 'settings.html': 'nav.settings', 'architecture.html': 'nav.architecture', 'simulate.html': 'nav.simulate'
};
var GROUP_I18N = {
'Stakeholders': 'nav.grp_stakeholders', 'Struttura interna': 'nav.grp_internal', 'Rischi': 'nav.grp_risks',
'Monitoraggio': 'nav.grp_monitoring', 'Lex e ISO': 'nav.grp_lexiso', 'AI': 'nav.grp_ai', 'Guida': 'nav.grp_guide'
};
function i18nAttr(key) { return key ? ' data-i18n="' + key + '"' : ''; }
// data-nav = href senza .html → aggancio per il puntatore guida ARIA (aria-guide.js).
function navAttr(href) { return ' data-nav="' + esc(String(href).replace(/\.html$/, '')) + '"'; }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
function currentPage() {
return (window.location.pathname.split('/').pop() || 'dashboard.html');
}
// È attivo il gruppo che contiene la pagina corrente (direct href o una delle links).
function isGroupActive(g, page) {
if (g.href) return g.href === page;
if (g.cols) {
for (var i = 0; i < g.cols.length; i++) {
var lk = g.cols[i].links;
for (var j = 0; j < lk.length; j++) if (lk[j].href === page) return true;
}
}
return false;
}
function renderMega(g) {
var colsHtml = g.cols.map(function (col) {
var links = col.links.map(function (l) {
return '<a class="mega-link"' + navAttr(l.href) + ' href="' + esc(l.href) + '">' +
'<span class="mega-ic"><i data-lucide="' + esc(l.ic) + '" class="ic"></i></span>' +
'<span><b' + i18nAttr(I18N_BY_HREF[l.href]) + '>' + esc(l.b) + '</b><span>' + esc(l.s) + '</span></span></a>';
}).join('');
return '<div class="mega-col"><p class="mega-head">' + esc(col.head) + '</p>' + links + '</div>';
}).join('');
return '<div class="mega no-promo">' + colsHtml + '</div>';
}
function renderNavItem(g, page) {
var active = isGroupActive(g, page);
var cls = 'nav-link' + (active ? ' active' : '');
var aria = active ? ' aria-current="page"' : '';
var i18n = i18nAttr(g.i18n || GROUP_I18N[g.label]);
if (g.href && !g.cols) {
// Voce diretta (Dashboard, Calendario)
return '<div class="nav-item">' +
'<a class="' + cls + '"' + aria + navAttr(g.href) + ' href="' + esc(g.href) + '">' +
'<i data-lucide="' + esc(g.icon) + '" class="ic"></i>' +
'<span' + i18n + '>' + esc(g.label) + '</span></a></div>';
}
// Gruppo con mega-menu. Il link "testa" punta alla prima voce del gruppo.
var firstHref = g.cols[0].links[0].href;
return '<div class="nav-item">' +
'<a class="' + cls + '"' + aria + navAttr(firstHref) + ' href="' + esc(firstHref) + '">' +
'<i data-lucide="' + esc(g.icon) + '" class="ic"></i>' +
'<span' + i18n + '>' + esc(g.label) + '</span>' +
'<i data-lucide="chevron-down" class="caret"></i></a>' +
renderMega(g) + '</div>';
}
function loadTopnavV3() {
// Container: usa #topnav se presente, altrimenti riusa #sidebar (compat),
// altrimenti crea un host e lo prepende al body.
var host = document.getElementById('topnav') || document.getElementById('sidebar');
if (!host) {
host = document.createElement('div');
host.id = 'topnav';
document.body.insertBefore(host, document.body.firstChild);
}
// Se riusiamo #sidebar (aside), neutralizziamo eventuali stili layout V2.
host.removeAttribute('class');
host.id = 'topnav';
var page = currentPage();
var lang = (typeof _getSavedLang === 'function') ? _getSavedLang() : 'it';
var navItems = NAV_GROUPS.map(function (g) { return renderNavItem(g, page); }).join('');
host.innerHTML =
'<header class="appbar">' +
'<div class="bar-top"><div class="bar-top-in">' +
'<span class="flag" aria-hidden="true"><i class="g"></i><i class="w"></i><i class="r"></i></span>' +
'<a class="logo-mark" href="dashboard.html" aria-label="NIS2 Agile — Dashboard">N2</a>' +
'<div class="brand-txt"><b class="sidebar-logo-text">NIS2 Agile</b>' +
'<span>Conformità Direttiva (UE) 2022/2555 · D.Lgs. 138/2024</span></div>' +
'<label class="search"><i data-lucide="search" class="ic"></i>' +
'<input id="topnav-search" placeholder="Cerca misura, rischio, incidente…" aria-label="Cerca"></label>' +
'<span class="spacer"></span>' +
// Org-switcher (consulenti / multi-org) — popolato da _loadUserInfo() via stesso id.
'<span id="sidebar-org-switcher" style="display:none"></span>' +
// Lingua IT/EN (riusa switchLang di common.js)
'<span id="sidebar-lang-toggle" class="topnav-lang" role="group" aria-label="Lingua">' +
'<button class="lang-btn ' + (lang === 'it' ? 'active' : '') + '" onclick="switchLang(\'it\')" title="Italiano" aria-label="Italiano">IT</button>' +
'<button class="lang-btn ' + (lang === 'en' ? 'active' : '') + '" onclick="switchLang(\'en\')" title="English" aria-label="English">EN</button>' +
'</span>' +
'<a class="btn-incident" href="incidents.html"><i data-lucide="siren" class="ic"></i><span data-i18n="nav.register_incident">Registra incidente</span></a>' +
// User menu (avatar+nome+ruolo via _loadUserInfo; click = menu)
'<span class="user" id="topnav-user" tabindex="0" role="button" aria-haspopup="true" aria-expanded="false">' +
'<span class="avatar" id="sidebar-user-avatar">--</span>' +
'<span class="sidebar-user-name" id="sidebar-user-name" style="max-width:140px;overflow:hidden;text-overflow:ellipsis">…</span>' +
'<span id="sidebar-user-role" hidden></span>' +
'<i data-lucide="chevron-down" class="ic"></i>' +
'</span>' +
'<button class="nav-toggle" id="topnav-toggle" aria-label="Apri menu" aria-expanded="false"><i data-lucide="menu" class="ic"></i></button>' +
'</div></div>' +
'<nav class="bar-nav" id="topnav-bar" aria-label="Navigazione principale"><div class="bar-nav-in">' +
navItems +
'</div></nav>' +
'</header>';
// ── Gate voce "Le mie aziende": visibile solo a consulenti / super_admin ──
try {
var role = (typeof api !== 'undefined' && api.getUserRole) ? api.getUserRole() : null;
if (['consultant', 'super_admin'].indexOf(role) === -1) {
var studioLink = host.querySelector('#topnav-bar a[data-nav="companies"]');
var studioItem = studioLink && studioLink.closest('.nav-item');
if (studioItem) studioItem.remove();
}
} catch (e) { /* in caso di errore lasciamo la voce (backend gate comunque presente) */ }
// ── Riuso helper common.js (utente/avatar/ruolo + org-switcher + versione + branding + impersonate) ──
if (typeof _loadUserInfo === 'function') _loadUserInfo();
if (typeof _loadVersionFooter === 'function') _loadVersionFooter();
if (typeof _loadFirmBranding === 'function') _loadFirmBranding();
if (typeof _renderImpersonateBanner === 'function') _renderImpersonateBanner();
// ── Menu utente: dropdown con versione + logout (riusa api.logout) ──
_wireUserMenu();
// ── Mobile: toggle nav + accordion mega ──
_wireMobileNav();
// ── Stile + montaggio del trittico FAB reale dentro .fab-stack ──
_styleRealFabs();
// Lucide: prova subito (se già caricato) — l'init pagina lo richiama comunque.
if (window.lucide && typeof lucide.createIcons === 'function') {
try { lucide.createIcons(); } catch (e) {}
}
}
// Dropdown del menu utente: versione (changelog) + logout.
function _wireUserMenu() {
var u = document.getElementById('topnav-user');
if (!u) return;
var menu = document.createElement('div');
menu.id = 'topnav-user-menu';
menu.style.cssText = 'position:fixed;display:none;min-width:200px;background:#fff;color:var(--ink);' +
'border:1px solid var(--line);border-radius:10px;box-shadow:0 14px 36px rgba(11,40,80,.20);' +
'padding:6px;z-index:90';
menu.innerHTML =
'<div id="sidebar-version" role="button" tabindex="0" title="Versione applicazione" ' +
'style="padding:8px 10px;font-size:12px;color:var(--muted);cursor:pointer;border-radius:8px">v—</div>' +
'<a href="settings.html" style="display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:8px;font-size:13px;font-weight:600">' +
'<i data-lucide="settings" style="width:15px;height:15px"></i>Impostazioni</a>' +
'<button type="button" id="topnav-logout" style="display:flex;align-items:center;gap:8px;width:100%;text-align:left;' +
'border:0;background:none;font:inherit;font-size:13px;font-weight:600;color:var(--c-red);padding:9px 10px;border-radius:8px;cursor:pointer">' +
'<i data-lucide="log-out" style="width:15px;height:15px"></i>Esci</button>';
document.body.appendChild(menu);
function place() {
var r = u.getBoundingClientRect();
menu.style.top = (r.bottom + 8) + 'px';
menu.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
}
function open() { place(); menu.style.display = 'block'; u.setAttribute('aria-expanded', 'true'); if (window.lucide) try { lucide.createIcons(); } catch (e) {} }
function close() { menu.style.display = 'none'; u.setAttribute('aria-expanded', 'false'); }
function toggle() { (menu.style.display === 'block') ? close() : open(); }
u.addEventListener('click', toggle);
u.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
document.addEventListener('click', function (e) { if (!u.contains(e.target) && !menu.contains(e.target)) close(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
var lo = menu.querySelector('#topnav-logout');
if (lo) lo.addEventListener('click', function () { if (typeof api !== 'undefined' && api.logout) api.logout(); });
var ver = menu.querySelector('#sidebar-version');
if (ver) {
ver.addEventListener('click', function () { if (typeof _showVersionChangelog === 'function') _showVersionChangelog(); });
ver.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (typeof _showVersionChangelog === 'function') _showVersionChangelog(); } });
}
}
// Mobile (≤900): hamburger apre/chiude la nav; tap su un gruppo apre l'accordion mega.
function _wireMobileNav() {
var toggle = document.getElementById('topnav-toggle');
var bar = document.getElementById('topnav-bar');
if (toggle && bar) {
toggle.addEventListener('click', function () {
var open = bar.classList.toggle('open');
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
// Accordion per i gruppi con mega (solo in modalità mobile via CSS).
document.querySelectorAll('#topnav-bar .nav-item').forEach(function (item) {
var hasMega = item.querySelector('.mega');
if (!hasMega) return;
var link = item.querySelector('.nav-link');
if (!link) return;
link.addEventListener('click', function (e) {
// Solo in layout mobile (nav-toggle visibile) intercettiamo il tap per aprire l'accordion.
if (toggle && getComputedStyle(toggle).display !== 'none') {
e.preventDefault();
item.classList.toggle('open');
}
});
});
}
// Trittico FAB reale (campana/bug-reporter ARIA) montato da common.js: lo
// raggruppiamo in .fab-stack e gli diamo la veste V3, senza reimplementarlo.
// common.js crea: #nx-bar (bug-reporter widget) e #ai-chat-fab (ARIA viola).
// Qui aggiungiamo SOLO un contenitore visivo se non presente; gli elementi
// restano gestiti dal loro codice (eventi/voce/puntatore invariati).
function _styleRealFabs() {
// I FAB reali sono position:fixed indipendenti (z-index alti). Non li
// spostiamo nel DOM per non romperne gli handler; il loro look resta
// quello di common.js. Questa funzione è un hook no-op di compatibilità
// mantenuto per chiarezza: la veste .fab del mockup NON deve sovrascrivere
// i FAB funzionanti (ARIA con voce + bug-reporter widget).
return;
}
// CSS one-shot per il lang-toggle e user menu nella appbar (token V3).
(function injectChromeCss() {
if (document.getElementById('topnav-v3-css')) return;
var s = document.createElement('style');
s.id = 'topnav-v3-css';
s.textContent =
'.topnav-lang{display:inline-flex;gap:3px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.16);border-radius:8px;padding:3px}' +
'.topnav-lang .lang-btn{border:0;background:transparent;color:#cfe0f1;font:inherit;font-size:11px;font-weight:700;padding:4px 8px;border-radius:6px;cursor:pointer}' +
'.topnav-lang .lang-btn:hover{background:rgba(255,255,255,.12);color:#fff}' +
'.topnav-lang .lang-btn.active{background:var(--gold);color:#1a1a1a}' +
'#topnav-user .sidebar-user-name{font-size:12px;font-weight:600}' +
'@media (max-width:560px){#topnav-user .sidebar-user-name{display:none}.topnav-lang{display:none}}';
document.head.appendChild(s);
})();
// Override globale: l'init delle pagine chiama loadSidebar() invariato.
window.loadTopnavV3 = loadTopnavV3;
window.loadSidebar = loadTopnavV3;
})();
+65 -47
View File
@@ -3,15 +3,36 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Knowledge Base - NIS2 Agile</title> <title>Knowledge Base · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
via JS (lista doc, search) -> parita' funzionale assoluta. BI veste la chrome. --> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<style> <style>
/* ── V3 compat bridge: token + componenti (card/form/btn/badge) ex style.css ── */
:root{
--primary:#1F5FA8; --gray-100:#f1f5f9; --gray-300:#cbd5e1; --gray-500:#64748b;
--gray-600:#475569; --gray-700:#334155;
}
.card-pad .card-header{padding:0 0 14px;border-bottom:1px solid var(--line);margin-bottom:16px}
.card-pad .card-header h3{margin:0;font-size:15px;font-weight:700}
.card-pad .card-body{padding:0}
.text-muted{color:var(--muted)} .text-danger{color:var(--c-red)}
.form-group{margin-bottom:0} .form-group + .form-group{margin-top:12px}
.form-control{width:100%;padding:9px 12px;border:1px solid var(--line);border-radius:8px;
font:inherit;font-size:13px;color:var(--ink);background:#fff}
.form-control:focus{outline:none;border-color:#9FBEDD;box-shadow:0 0 0 3px rgba(31,95,168,.12)}
label{font-size:12.5px;font-weight:600;color:var(--ink);display:block;margin-bottom:4px}
.btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;padding:8px 13px;
font:inherit;font-weight:600;font-size:12.5px;cursor:pointer;border:1px solid var(--line);background:#fff;color:var(--ink)}
.btn:hover{background:#F5F8FC}
.btn-primary{background:var(--c-blue);color:#fff;border-color:var(--c-blue)} .btn-primary:hover{background:#1A5494}
.btn-secondary{background:#fff;color:var(--ink)}
.btn-danger{background:var(--c-red);color:#fff;border-color:var(--c-red)} .btn-danger:hover{filter:brightness(.95)}
.btn-sm{padding:5px 10px;font-size:11.5px}
.btn .icon{width:15px;height:15px}
.kb-scope-radios { display: flex; gap: 18px; flex-wrap: wrap; font-size: .9rem; } .kb-scope-radios { display: flex; gap: 18px; flex-wrap: wrap; font-size: .9rem; }
.kb-scope-radios label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; } .kb-scope-radios label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; margin-bottom:0; }
.kb-share-block { margin-top: 12px; } .kb-share-block { margin-top: 12px; }
.kb-doc-list { margin-top: 24px; } .kb-doc-list { margin-top: 24px; }
.kb-doc-row { padding: 10px 12px; border-bottom: 1px solid var(--gray-100); display: flex; justify-content: space-between; align-items: center; } .kb-doc-row { padding: 10px 12px; border-bottom: 1px solid var(--gray-100); display: flex; justify-content: space-between; align-items: center; }
@@ -19,8 +40,6 @@
.kb-scope-badge.SYSTEM { background: #DBEAFE; color: #1E40AF; } .kb-scope-badge.SYSTEM { background: #DBEAFE; color: #1E40AF; }
.kb-scope-badge.FIRM { background: #FEF3C7; color: #92400E; } .kb-scope-badge.FIRM { background: #FEF3C7; color: #92400E; }
.kb-scope-badge.ORG { background: #DCFCE7; color: #166534; } .kb-scope-badge.ORG { background: #DCFCE7; color: #166534; }
/* Allinea l'icona sprite BI dentro i bottoni del design system esistente */
.btn .icon { fill: currentColor; vertical-align: middle; }
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -37,19 +56,23 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content"> <main class="wrap">
<header class="content-header"> <nav class="crumb" aria-label="breadcrumb">
<h1 class="h2">Knowledge Base</h1> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<div class="content-header-actions"> <i data-lucide="chevron-right" class="ic"></i><span>Knowledge Base</span>
<button class="btn btn-primary" id="btn-kb-upload"> </nav>
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-upload"></use></svg> <div class="page-head">
Carica documento <div>
</button> <h1>Knowledge Base</h1>
</div> <p class="sub">Documenti che l'AI usa per rispondere con citazioni precise</p>
</header> </div>
<button class="btn-primary" id="btn-kb-upload">
<i data-lucide="upload" class="ic"></i>Carica documento
</button>
</div>
<div class="content-body"> <div class="content-body">
<p class="text-muted" style="margin-top:0;"> <p class="text-muted" style="margin-top:0;">
@@ -60,7 +83,7 @@
</p> </p>
<!-- Form upload (hidden by default) --> <!-- Form upload (hidden by default) -->
<div class="card" id="kb-form-card" style="display:none;margin-top:16px;"> <div class="card card-pad" id="kb-form-card" style="display:none;margin-top:16px;">
<div class="card-body"> <div class="card-body">
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;"> <div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-group"> <div class="form-group">
@@ -120,7 +143,7 @@
</div> </div>
<!-- Lista documenti --> <!-- Lista documenti -->
<div class="card kb-doc-list"> <div class="card card-pad kb-doc-list">
<div class="card-header"> <div class="card-header">
<h3>Documenti caricati visibili</h3> <h3>Documenti caricati visibili</h3>
</div> </div>
@@ -132,47 +155,42 @@
</div> </div>
<!-- Search preview --> <!-- Search preview -->
<div class="card" style="margin-top:24px;"> <div class="card card-pad" style="margin-top:24px;">
<div class="card-header"><h3>Test search semantica</h3></div> <div class="card-header"><h3>Test search semantica</h3></div>
<div class="card-body"> <div class="card-body">
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;align-items:flex-end;">
<label for="kb-search-query" class="sr-only visually-hidden">Query di ricerca semantica</label> <div style="flex:1;">
<input type="text" id="kb-search-query" class="form-control" placeholder="Es: come gestire un incidente NIS2?" aria-label="Query di ricerca semantica"> <label for="kb-search-query">Query di ricerca semantica</label>
<input type="text" id="kb-search-query" class="form-control" placeholder="Es: come gestire un incidente NIS2?" aria-label="Query di ricerca semantica">
</div>
<button class="btn btn-secondary" id="btn-kb-search"> <button class="btn btn-secondary" id="btn-kb-search">
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-search"></use></svg> <i data-lucide="search" class="icon"></i>Cerca
Cerca
</button> </button>
</div> </div>
<div id="kb-search-results" style="margin-top:12px;"></div> <div id="kb-search-results" style="margin-top:12px;"></div>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
</div>
<!-- Stessa logica JS della pagina live (parita' funzionale assoluta, zero backend). --> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="/js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → kb.js -->
<script src="/js/api.js?v=20260631"></script> <script src="/js/api.js?v=20260730a"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <script src="/js/common.js?v=20260729b"></script>
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. --> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script> <script src="/js/i18n.js?v=20260731"></script>
if (window.bootstrap && bootstrap.loadFonts) { <script src="/js/help.js?v=20260806b"></script>
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</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/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 V3.
if (typeof checkAuth === 'function' && !checkAuth()) { if (typeof checkAuth === 'function' && !checkAuth()) {
// checkAuth reindirizza a login se non autenticato // checkAuth reindirizza a login se non autenticato
} else { } else {
if (typeof loadSidebar === 'function') loadSidebar(); if (typeof loadSidebar === 'function') loadSidebar(); // = loadTopnavV3()
if (typeof I18n !== 'undefined' && I18n.init) I18n.init(); if (typeof I18n !== 'undefined' && I18n.init) I18n.init();
if (typeof HelpSystem !== 'undefined' && HelpSystem.init) HelpSystem.init(); if (typeof HelpSystem !== 'undefined' && HelpSystem.init) HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
} }
</script> </script>
</body> </body>
+1 -1
View File
@@ -515,7 +515,7 @@ body { background: var(--bg-main); }
<div id="toast"></div> <div id="toast"></div>
<script src="js/api.js?v=20260631"></script> <script src="js/api.js?v=20260730a"></script>
<script> <script>
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
// CONFIG // CONFIG
+2 -2
View File
@@ -94,8 +94,8 @@
</script> </script>
<!-- Stessa logica della login attuale: ZERO modifiche backend --> <!-- Stessa logica della login attuale: ZERO modifiche backend -->
<script src="js/api.js?v=20260631"></script> <script src="js/api.js?v=20260730a"></script>
<script src="js/common.js?v=20260631"></script> <script src="js/common.js?v=20260729b"></script>
<script> <script>
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; } if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>NIS2 Agile — Manutenzione in corso</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<style>
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
background:#0A2E54;color:#fff;font-family:system-ui,-apple-system,'Segoe UI',Roboto,Arial,sans-serif}
.box{max-width:540px;text-align:center;padding:44px 28px}
.logo{width:62px;height:62px;border-radius:15px;background:#1E8A5A;display:inline-flex;align-items:center;
justify-content:center;font-weight:800;font-size:21px;margin-bottom:20px}
h1{font-size:27px;margin:0 0 12px;letter-spacing:-.3px}
p{color:#cfe0f3;line-height:1.65;font-size:15px;margin:0 0 14px}
.win{display:inline-block;margin-top:10px;background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.18);
border-radius:11px;padding:13px 20px;font-size:15px;line-height:1.5}
.win b{color:#F0B429}
.small{font-size:12.5px;color:#9db8d2;margin-top:24px}
</style>
</head>
<body>
<div class="box">
<div class="logo">N2</div>
<h1>Manutenzione in corso</h1>
<p>Stiamo aggiornando l'<strong>interfaccia (UI)</strong> di NIS2 Agile su richiesta di modifica.
Il servizio non è momentaneamente accessibile.</p>
<div class="win">Finestra di manutenzione: <b>oggi dalle 10:39 alle ~12:15</b> (ora italiana)<br>durata stimata <b>circa 1–1,5 ore</b></div>
<p class="small">Grazie per la pazienza. Non occorre fare nulla: l'accesso tornerà disponibile automaticamente al termine.</p>
</div>
</body>
</html>
+43 -21
View File
@@ -3,10 +3,21 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Riesame di Direzione - NIS2 Agile</title> <title>Riesame di Direzione · NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia CDN). -->
<link rel="stylesheet" href="/css/style.css?v=20260618"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style> <style>
/* ── Compat V3: token + bottoni emessi dal JS (ex style.css, niente CDN) ── */
:root{
--primary:#1F5FA8;--gray-100:#F1F5F9;--gray-200:#E3E9F0;--gray-400:#94A3B8;--gray-500:#64748B;
}
.btn{display:inline-flex;align-items:center;gap:7px;border:1px solid transparent;border-radius:8px;padding:8px 13px;font:inherit;font-weight:600;font-size:12.5px;cursor:pointer;line-height:1.2}
.btn:disabled{opacity:.55;cursor:not-allowed}
.btn-primary{background:var(--c-blue);color:#fff}.btn-primary:hover:not(:disabled){background:#1A5494}
.btn-outline{background:#fff;color:var(--ink);border-color:var(--line)}.btn-outline:hover:not(:disabled){background:#F5F8FC;border-color:#c7d4e4}
.btn-sm{padding:5px 11px;font-size:11.5px}
a.btn{text-decoration:none}
.mr-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; } .mr-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
.mr-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; } .mr-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
.mr-toolbar { display:flex; justify-content:flex-end; margin-bottom:12px; } .mr-toolbar { display:flex; justify-content:flex-end; margin-bottom:12px; }
@@ -62,13 +73,23 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content">
<header class="content-header"> <main class="wrap">
<h2 data-i18n="mreview.title">Riesame di Direzione</h2> <nav class="crumb" aria-label="breadcrumb">
</header> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<div class="content-body"> <i data-lucide="chevron-right" class="ic"></i><span>Monitoraggio</span>
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="mreview.title">Riesame di Direzione</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="mreview.title">Riesame di Direzione</h1>
<p class="sub">Verbale del riesame periodico del SGSI: input aggregati, decisioni e approvazione (ISO/IEC 27001 §9.3)</p>
</div>
<button class="btn-primary" onclick="mrOpen()"><i data-lucide="plus" class="ic"></i>Nuovo riesame</button>
</div>
<section class="card card-pad">
<div class="mr-intro"> <div class="mr-intro">
<strong>Riesame periodico del SGSI da parte della direzione.</strong> <strong>Riesame periodico del SGSI da parte della direzione.</strong>
Produce il <em>verbale</em> con gli elementi in ingresso (aggregati automaticamente dai moduli esistenti) e in uscita Produce il <em>verbale</em> con gli elementi in ingresso (aggregati automaticamente dai moduli esistenti) e in uscita
@@ -79,11 +100,9 @@
art. 23 D.Lgs. 138/2024). Strumento di supporto e pre-audit: non sostituisce l'auditor. art. 23 D.Lgs. 138/2024). Strumento di supporto e pre-audit: non sostituisce l'auditor.
</div> </div>
<div class="mr-toolbar"><button class="btn btn-primary btn-sm" onclick="mrOpen()">+ Nuovo riesame</button></div>
<div id="mr-list" aria-live="polite"></div> <div id="mr-list" aria-live="polite"></div>
</div> </section>
</main> </main>
</div>
<!-- Modale dettaglio / verbale --> <!-- Modale dettaglio / verbale -->
<div class="sm-overlay" id="mr-modal" role="dialog" aria-modal="true" aria-labelledby="mr-modal-title"> <div class="sm-overlay" id="mr-modal" role="dialog" aria-modal="true" aria-labelledby="mr-modal-title">
@@ -126,12 +145,14 @@
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="/js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → init pagina -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/api.js?v=20260730a"></script>
<script src="/js/common-bi.js?v=20260631"></script> <script src="/js/common.js?v=20260729b"></script>
<script src="/js/i18n.js?v=20260631"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/help.js?v=20260618p2"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
'use strict'; 'use strict';
/* /*
@@ -160,9 +181,10 @@
document.addEventListener('DOMContentLoaded', async function () { document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return; if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar(); if (typeof loadSidebar === 'function') loadSidebar();
if (window.I18n && I18n.init) I18n.init('it');
if (window.HelpSystem && HelpSystem.init) HelpSystem.init(); if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
document.addEventListener('keydown', e => { if (e.key === 'Escape') mrClose(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') mrClose(); });
const ov = el('mr-modal'); if (ov) ov.addEventListener('click', e => { if (e.target === ov) mrClose(); }); const ov = el('mr-modal'); if (ov) ov.addEventListener('click', e => { if (e.target === ov) mrClose(); });
await mrLoadAll(); await mrLoadAll();
+115 -117
View File
@@ -3,35 +3,35 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<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. --> <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/style.css?v=20260618"> <link rel="stylesheet" href="/css/v3.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; } /* V3 — specifico pagina Misure/Requisiti (non coperto da v3.css) */
.mr-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; } .mr-intro { background:#EAF1FA; border-left:4px solid var(--c-blue); padding:14px 18px; border-radius:10px; margin-bottom:14px; font-size:13.5px; line-height:1.6; color:var(--ink); }
.mr-ro { font-size:.8rem; color:#3730a3; background:#eef2ff; border:1px solid #c7d2fe; border-radius:8px; padding:8px 12px; margin:0 0 16px; display:flex; gap:8px; align-items:center; } .mr-note { font-size:12.5px; color:#92400e; background:#FBF1E2; border:1px solid #fde68a; border-radius:10px; padding:10px 12px; margin:0 0 16px; }
.mr-stats { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:16px; } .mr-ro { font-size:12.5px; color:#3730a3; background:#eef2ff; border:1px solid #c7d2fe; border-radius:10px; padding:8px 12px; margin:0 0 16px; display:flex; gap:8px; align-items:center; }
.mr-stat { flex:1 1 140px; min-width:120px; background:#fff; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; padding:12px 14px; } .mr-stats { display:flex; flex-wrap:wrap; gap:12px; margin-bottom:16px; }
.mr-stat .n { font-size:1.5rem; font-weight:800; line-height:1; } .mr-stat { flex:1 1 140px; min-width:120px; background:#fff; border:1px solid var(--line); border-radius:11px; padding:14px 16px; box-shadow:var(--shadow); }
.mr-stat .l { font-size:.76rem; color:var(--gray-500,#6b7280); margin-top:4px; } .mr-stat .n { font-size:27px; font-weight:800; line-height:1; color:var(--c-blue); }
.mr-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; } .mr-stat .l { font-size:11px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:#7a8aa0; margin-top:6px; }
.mr-toolbar label { font-weight:600; font-size:.82rem; } .mr-toolbar { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.mr-toolbar select, .mr-toolbar input[type=search] { padding:8px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.88rem; } .mr-toolbar label { font-weight:600; font-size:13px; color:var(--ink); }
.mr-toolbar select, .mr-toolbar input[type=search] { padding:8px 11px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:13.5px; background:#fff; color:var(--ink); }
.mr-toolbar .grow { flex:1 1 auto; } .mr-toolbar .grow { flex:1 1 auto; }
.mr-group { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; margin-bottom:14px; overflow:hidden; } .mr-group { border:1px solid var(--line); border-radius:11px; margin-bottom:14px; overflow:hidden; }
.mr-group-head { background:#f8fafc; padding:11px 14px; border-bottom:1px solid var(--gray-100,#f3f4f6); } .mr-group-head { background:#FAFCFE; padding:12px 16px; border-bottom:1px solid var(--line); }
.mr-group-head .code { font-weight:800; color:#1e3a8a; font-size:.95rem; } .mr-group-head .code { font-weight:800; color:#1e3a8a; font-size:14px; }
.mr-group-head .area { font-size:.78rem; color:var(--gray-500,#6b7280); } .mr-group-head .area { font-size:12px; color:var(--muted); }
.mr-group-head .descr { font-size:.86rem; color:#334155; margin-top:3px; line-height:1.5; } .mr-group-head .descr { font-size:13px; color:#334155; margin-top:3px; line-height:1.5; }
.mr-tag { display:inline-block; font-size:.66rem; font-weight:800; padding:2px 8px; border-radius:20px; margin-left:6px; vertical-align:middle; } .mr-tag { display:inline-block; font-size:10px; font-weight:800; padding:2px 8px; border-radius:20px; margin-left:6px; vertical-align:middle; }
.mr-tag.ess { background:#fef3c7; color:#92400e; } .mr-tag.ess { background:#FBF1E2; color:#92400e; }
.mr-table { width:100%; border-collapse:collapse; font-size:.85rem; } .mr-table { width:100%; border-collapse:collapse; font-size:13px; }
.mr-table th, .mr-table td { text-align:left; padding:9px 11px; border-bottom:1px solid var(--gray-100,#f3f4f6); vertical-align:top; } .mr-table th, .mr-table td { text-align:left; padding:9px 12px; border-bottom:1px solid var(--line); vertical-align:top; }
.mr-table th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); background:#fff; position:sticky; top:0; } .mr-table th { font-size:10.5px; text-transform:uppercase; letter-spacing:.04em; color:#7a8aa0; background:#fff; position:sticky; top:0; }
.mr-req-code { white-space:nowrap; font-weight:700; color:#475569; } .mr-req-code { white-space:nowrap; font-weight:700; color:#475569; }
.mr-code-pill { display:inline-block; font-size:.7rem; font-weight:700; color:#3730a3; background:#eef2ff; border-radius:6px; padding:1px 6px; } .mr-code-pill { display:inline-block; font-size:11px; font-weight:700; color:#3730a3; background:#eef2ff; border-radius:6px; padding:1px 6px; }
/* Ticket #384 p.3 — codici Policy come link attivi verso il menu POLICY */
a.mr-code-link { text-decoration:none; cursor:pointer; margin-right:3px; } a.mr-code-link { text-decoration:none; cursor:pointer; margin-right:3px; }
a.mr-code-link:hover { background:#e0e7ff; text-decoration:underline; } a.mr-code-link:hover { background:#e0e7ff; text-decoration:underline; }
a.mr-code-link:focus-visible { outline:2px solid #3730a3; outline-offset:1px; } a.mr-code-link:focus-visible { outline:2px solid #3730a3; outline-offset:1px; }
@@ -41,31 +41,32 @@
.mr-yn.n { color:#cbd5e1; } .mr-yn.n { color:#cbd5e1; }
.mr-row.spento { opacity:.4; } .mr-row.spento { opacity:.4; }
.mr-row.spento td { font-style:italic; } .mr-row.spento td { font-style:italic; }
.mr-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); } .mr-empty { text-align:center; padding:34px 16px; color:var(--muted); }
.sv-badge { display:inline-block; font-size:.72rem; font-weight:800; padding:3px 10px; border-radius:20px; border:none; cursor:pointer; } .savehint { font-size:12.5px; color:var(--muted); min-height:16px; }
.sv-badge.sv-todo { background:#f1f5f9; color:#475569; } .sv-badge { display:inline-block; font-size:11px; font-weight:800; padding:3px 10px; border-radius:20px; border:none; cursor:pointer; }
.sv-badge.sv-todo { background:#EEF2F7; color:#475569; }
.sv-badge.sv-na { background:#e5e7eb; color:#6b7280; } .sv-badge.sv-na { background:#e5e7eb; color:#6b7280; }
.sv-badge.sv-ko { background:#fee2e2; color:#991b1b; } .sv-badge.sv-ko { background:#FBE9E9; color:#991b1b; }
.sv-badge.sv-mid { background:#fef3c7; color:#92400e; } .sv-badge.sv-mid { background:#FBF1E2; color:#92400e; }
.sv-badge.sv-ok { background:#dcfce7; color:#166534; } .sv-badge.sv-ok { background:#E6F4EC; color:#166534; }
.sv-val { font-size:.76rem; color:#64748b; margin-top:4px; max-width:300px; } .sv-val { font-size:11.5px; color:#64748b; margin-top:4px; max-width:300px; }
/* Modale valutazione */ /* Modale valutazione (page-owned) */
.rm-overlay { display:none; position:fixed; inset:0; background:rgba(15,23,42,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 16px; } .rm-overlay { display:none; position:fixed; inset:0; background:rgba(15,27,46,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 16px; }
.rm-overlay.open { display:flex; } .rm-overlay.open { display:flex; }
.rm-dialog { background:#fff; border-radius:12px; width:100%; max-width:600px; box-shadow:0 20px 50px rgba(0,0,0,.25); } .rm-dialog { background:#fff; border-radius:12px; width:100%; max-width:600px; box-shadow:0 20px 50px rgba(11,40,80,.25); }
.rm-head { display:flex; justify-content:space-between; align-items:flex-start; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); } .rm-head { display:flex; justify-content:space-between; align-items:flex-start; padding:16px 20px; border-bottom:1px solid var(--line); }
.rm-head h3 { margin:0; font-size:1.02rem; } .rm-head h3 { margin:0; font-size:16px; }
.rm-head .sub { font-size:.8rem; color:var(--gray-500,#6b7280); margin-top:4px; } .rm-head .sub { font-size:12.5px; color:var(--muted); margin-top:4px; }
.rm-body { padding:18px 20px; } .rm-body { padding:18px 20px; }
.rm-foot { padding:14px 20px; border-top:1px solid var(--gray-100,#f3f4f6); display:flex; justify-content:flex-end; gap:10px; } .rm-foot { padding:14px 20px; border-top:1px solid var(--line); display:flex; justify-content:flex-end; gap:10px; }
.rm-field { margin-bottom:14px; } .rm-field { margin-bottom:14px; }
.rm-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; } .rm-field label { display:block; font-weight:600; font-size:13px; margin-bottom:4px; }
.rm-field select, .rm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; } .rm-field select, .rm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:14px; }
.rm-field textarea { min-height:80px; resize:vertical; } .rm-field textarea { min-height:80px; resize:vertical; }
.rm-ctx { background:#f8fafc; border:1px solid var(--gray-100,#f3f4f6); border-radius:8px; padding:10px 12px; font-size:.83rem; margin-bottom:14px; line-height:1.5; } .rm-ctx { background:#FAFCFE; border:1px solid var(--line); border-radius:8px; padding:10px 12px; font-size:13px; margin-bottom:14px; line-height:1.5; }
.rm-ctx .pill { display:inline-block; font-size:.7rem; font-weight:700; color:#3730a3; background:#eef2ff; border-radius:6px; padding:1px 6px; margin-right:4px; } .rm-ctx .pill { display:inline-block; font-size:11px; font-weight:700; color:#3730a3; background:#eef2ff; border-radius:6px; padding:1px 6px; margin-right:4px; }
.rm-err { color:#b91c1c; font-size:.82rem; min-height:18px; } .rm-err { color:#b91c1c; font-size:12.5px; min-height:18px; }
.rm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); } .rm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--muted); }
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -82,56 +83,62 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="mr.title">Misure e Requisiti</h2>
<div class="content-header-actions">
<span class="savehint" id="mr-hint" role="status" aria-live="polite"></span>
</div>
</header>
<div class="content-body"> <main class="wrap">
<div class="mr-intro"> <nav class="crumb" aria-label="breadcrumb">
<strong>L'elenco canonico delle misure di sicurezza di base e dei relativi requisiti.</strong> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
Ogni requisito riporta la <strong>policy di default</strong> che lo soddisfa e il <strong>rischio di default</strong> associato. È la base su cui si costruiscono policy, rischi e attività: le righe si "spengono" quando non sono applicabili alla classe del tuo soggetto. <i data-lucide="chevron-right" class="ic"></i><span>Lex e ISO</span>
</div> <i data-lucide="chevron-right" class="ic"></i><span>Misure e requisiti</span>
<div class="mr-ro"> </nav>
🔒 <span>Elenco di riferimento, <strong>sola lettura</strong>: la codifica (misure, requisiti, ordine) non è modificabile. Policy e rischi qui elencati vengono proposti come <em>default</em> nei rispettivi moduli, dove potrai personalizzarli.</span> <div class="page-head">
</div> <div>
<div class="mr-note"> <h1 data-i18n="mr.title">Misure e Requisiti</h1>
Fonte: <strong>Determinazione ACN n. 164179 del 14 aprile 2025</strong> (misure di base, Allegati 1–2). Le misure applicabili dipendono dalla classe del soggetto: <strong>importanti</strong> 37 misure / 92 requisiti, <strong>essenziali</strong> 43 / 116. Strumento di supporto, non un parere legale. <p class="sub">Elenco canonico delle misure di base ACN e dei relativi requisiti</p>
</div>
<div class="mr-stats" id="mr-stats" style="display:none;">
<div class="mr-stat"><div class="n" id="mr-stat-mis">0</div><div class="l">Misure</div></div>
<div class="mr-stat"><div class="n" id="mr-stat-req">0</div><div class="l">Requisiti</div></div>
<div class="mr-stat"><div class="n" id="mr-stat-active">0</div><div class="l">Applicabili al soggetto</div></div>
</div>
<div class="mr-toolbar">
<label for="mr-view">Vista:</label>
<select id="mr-view" onchange="mrRender()">
<option value="misura">Per misura</option>
<option value="area">Per area politica</option>
</select>
<label for="mr-class">Soggetto:</label>
<select id="mr-class" onchange="mrRender()">
<option value="auto">Automatico (dalla mia org)</option>
<option value="important">Importante</option>
<option value="essential">Essenziale</option>
<option value="all">Tutti (mostra tutto)</option>
</select>
<label style="font-weight:500;"><input type="checkbox" id="mr-hide" onchange="mrRender()"> nascondi non applicabili</label>
<span class="grow"></span>
<input type="search" id="mr-q" placeholder="Cerca codice o testo…" oninput="mrRender()" style="min-width:200px;">
</div>
<div id="mr-list" aria-live="polite"><div class="mr-empty">Caricamento…</div></div>
</div> </div>
</main> <span class="savehint" id="mr-hint" role="status" aria-live="polite"></span>
</div> </div>
<section class="card card-pad">
<div class="mr-intro">
<strong>L'elenco canonico delle misure di sicurezza di base e dei relativi requisiti.</strong>
Ogni requisito riporta la <strong>policy di default</strong> che lo soddisfa e il <strong>rischio di default</strong> associato. È la base su cui si costruiscono policy, rischi e attività: le righe si "spengono" quando non sono applicabili alla classe del tuo soggetto.
</div>
<div class="mr-ro">
🔒 <span>Elenco di riferimento, <strong>sola lettura</strong>: la codifica (misure, requisiti, ordine) non è modificabile. Policy e rischi qui elencati vengono proposti come <em>default</em> nei rispettivi moduli, dove potrai personalizzarli.</span>
</div>
<div class="mr-note">
Fonte: <strong>Determinazione ACN n. 164179 del 14 aprile 2025</strong> (misure di base, Allegati 1–2). Le misure applicabili dipendono dalla classe del soggetto: <strong>importanti</strong> 37 misure / 92 requisiti, <strong>essenziali</strong> 43 / 116. Strumento di supporto, non un parere legale.
</div>
<div class="mr-stats" id="mr-stats" style="display:none;">
<div class="mr-stat"><div class="n" id="mr-stat-mis">0</div><div class="l">Misure</div></div>
<div class="mr-stat"><div class="n" id="mr-stat-req">0</div><div class="l">Requisiti</div></div>
<div class="mr-stat"><div class="n" id="mr-stat-active">0</div><div class="l">Applicabili al soggetto</div></div>
</div>
<div class="mr-toolbar">
<label for="mr-view">Vista:</label>
<select id="mr-view" onchange="mrRender()">
<option value="misura">Per misura</option>
<option value="area">Per area politica</option>
</select>
<label for="mr-class">Soggetto:</label>
<select id="mr-class" onchange="mrRender()">
<option value="auto">Automatico (dalla mia org)</option>
<option value="important">Importante</option>
<option value="essential">Essenziale</option>
<option value="all">Tutti (mostra tutto)</option>
</select>
<label style="font-weight:500;"><input type="checkbox" id="mr-hide" onchange="mrRender()"> nascondi non applicabili</label>
<span class="grow"></span>
<input type="search" id="mr-q" placeholder="Cerca codice o testo…" oninput="mrRender()" style="min-width:200px;">
</div>
<div id="mr-list" aria-live="polite"><div class="mr-empty">Caricamento…</div></div>
</section>
</main>
<!-- Modale valutazione del requisito (org-scoped) --> <!-- Modale valutazione del requisito (org-scoped) -->
<div class="rm-overlay" id="mr-modal" role="dialog" aria-modal="true" aria-labelledby="mr-modal-title"> <div class="rm-overlay" id="mr-modal" role="dialog" aria-modal="true" aria-labelledby="mr-modal-title">
@@ -161,18 +168,19 @@
<div class="rm-err" id="mr-eval-err" role="alert"></div> <div class="rm-err" id="mr-eval-err" role="alert"></div>
</div> </div>
<div class="rm-foot"> <div class="rm-foot">
<button class="btn btn-outline" type="button" onclick="mrCloseEval()">Annulla</button> <button class="btn-ghost" type="button" onclick="mrCloseEval()">Annulla</button>
<button class="btn btn-primary" type="button" id="mr-eval-save" onclick="mrSaveEval()">Salva</button> <button class="btn-primary" type="button" id="mr-eval-save" onclick="mrSaveEval()">Salva</button>
</div> </div>
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3 (ordine ESATTO come dashboard.html) -->
<script src="/js/common.js?v=20260631"></script> <script src="/js/api.js?v=20260730a"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/common.js?v=20260729b"></script>
<script src="/js/common-bi.js?v=20260631"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/i18n.js?v=20260631"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/help.js?v=20260618p2"></script> <script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
'use strict'; 'use strict';
/* /*
@@ -185,12 +193,9 @@
function el(id) { return document.getElementById(id); } function el(id) { return document.getElementById(id); }
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; } function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
// Ticket #384 p.2.1 — i codici procedura/policy si presentano con prefisso "Policy." (Proc.NN -> Policy.NN). // Ticket #384 p.2.1 — i codici procedura/policy si presentano con prefisso "Policy." (Proc.NN -> Policy.NN).
// Il codice canonico nel catalogo resta proc_code (chiave di join requisiti<->procedure): qui si rietichetta solo per la UI.
function policyCode(code) { return code ? String(code).replace(/^Proc\./i, 'Policy.') : code; } function policyCode(code) { return code ? String(code).replace(/^Proc\./i, 'Policy.') : code; }
// Ticket #384 p.2.2/p.3 — colonna "Policy (default)" multi-valore con link attivi al menu POLICY. // Ticket #384 p.2.2/p.3 — colonna "Policy (default)" multi-valore con link attivi al menu POLICY.
// Usa le policy d'organizzazione collegate (req.policies dal backend); in assenza degrada
// al codice di default del catalogo (proc_code), com'era prima.
function renderPolicyLinks(req) { function renderPolicyLinks(req) {
const pols = req.policies || []; const pols = req.policies || [];
if (pols.length) { if (pols.length) {
@@ -220,6 +225,7 @@
if (window.I18n && I18n.init) I18n.init('it'); if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar(); if (typeof loadSidebar === 'function') loadSidebar();
if (window.HelpSystem && HelpSystem.init) HelpSystem.init(); if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
await mrLoad(); await mrLoad();
}); });
@@ -230,17 +236,14 @@
MR.org_class = (data && data.org_class) || null; MR.org_class = (data && data.org_class) || null;
MR.has_org = !!(data && data.has_org); MR.has_org = !!(data && data.has_org);
MR.stati = (data && data.stati) || Object.keys(STATO_META); MR.stati = (data && data.stati) || Object.keys(STATO_META);
// indicizza i requisiti per id (per la modale)
MR.byId = {}; MR.byId = {};
MR.measures.forEach(function (m) { (m.requisiti || []).forEach(function (q) { MR.byId[q.id] = q; }); }); MR.measures.forEach(function (m) { (m.requisiti || []).forEach(function (q) { MR.byId[q.id] = q; }); });
// se conosco la classe dell'org, parto in modalità "auto"; altrimenti mostro tutto
if (!MR.org_class) el('mr-class').value = 'all'; if (!MR.org_class) el('mr-class').value = 'all';
el('mr-stat-mis').textContent = (data && data.totals && data.totals.misure) || MR.measures.length; el('mr-stat-mis').textContent = (data && data.totals && data.totals.misure) || MR.measures.length;
el('mr-stat-req').textContent = (data && data.totals && data.totals.requisiti) || 0; el('mr-stat-req').textContent = (data && data.totals && data.totals.requisiti) || 0;
el('mr-stats').style.display = 'flex'; el('mr-stats').style.display = 'flex';
// Ticket #384 p.3 — deep-link dal menu POLICY: ?requisito=ID → mostra ed evidenzia il requisito.
const focusReq = parseInt(new URLSearchParams(location.search).get('requisito') || '', 10); const focusReq = parseInt(new URLSearchParams(location.search).get('requisito') || '', 10);
if (focusReq) el('mr-class').value = 'all'; // evita che la riga sia filtrata via if (focusReq) el('mr-class').value = 'all';
mrRender(); mrRender();
if (focusReq) mrFocusRequisito(focusReq); if (focusReq) mrFocusRequisito(focusReq);
} catch (e) { } catch (e) {
@@ -248,7 +251,6 @@
} }
} }
// Ticket #384 p.3 — scorre alla riga del requisito e la evidenzia (arrivo da link Policy).
function mrFocusRequisito(reqId) { function mrFocusRequisito(reqId) {
const row = document.getElementById('req-' + reqId); const row = document.getElementById('req-' + reqId);
if (!row) return; if (!row) return;
@@ -257,14 +259,12 @@
setTimeout(function () { row.classList.remove('mr-row-focus'); }, 4000); setTimeout(function () { row.classList.remove('mr-row-focus'); }, 4000);
} }
// classe effettiva selezionata (risolve "auto" sulla classe dell'org)
function mrEffectiveClass() { function mrEffectiveClass() {
const sel = el('mr-class').value; const sel = el('mr-class').value;
if (sel === 'auto') return MR.org_class; // può essere null => tutto attivo if (sel === 'auto') return MR.org_class;
if (sel === 'all') return null; if (sel === 'all') return null;
return sel; // important | essential return sel;
} }
// un requisito è "attivo" per la classe effettiva?
function reqActive(req, cls) { function reqActive(req, cls) {
if (!cls) return true; if (!cls) return true;
return cls === 'important' ? !!req.applies_important : !!req.applies_essential; return cls === 'important' ? !!req.applies_important : !!req.applies_essential;
@@ -283,9 +283,8 @@
const q = (el('mr-q').value || '').trim(); const q = (el('mr-q').value || '').trim();
const view = el('mr-view').value; const view = el('mr-view').value;
// costruisco le righe filtrate (misura -> requisiti visibili)
let activeCount = 0; let activeCount = 0;
const groupsByKey = {}; // key -> {label, descr, area, code, essOnly, rows:[]} const groupsByKey = {};
MR.measures.forEach(function (m) { MR.measures.forEach(function (m) {
const essOnly = !m.applies_important; const essOnly = !m.applies_important;
@@ -398,7 +397,6 @@
(function () { (function () {
const ov = el('mr-modal'); if (!ov) return; const ov = el('mr-modal'); if (!ov) return;
ov.addEventListener('click', function (e) { if (e.target === ov) mrCloseEval(); }); ov.addEventListener('click', function (e) { if (e.target === ov) mrCloseEval(); });
// focus-trap: Tab/Shift+Tab restano dentro la modale aperta
ov.addEventListener('keydown', function (e) { ov.addEventListener('keydown', function (e) {
if (e.key !== 'Tab' || !ov.classList.contains('open')) return; if (e.key !== 'Tab' || !ov.classList.contains('open')) return;
const nodes = ov.querySelectorAll('select, textarea, button'); const nodes = ov.querySelectorAll('select, textarea, button');
+1 -1
View File
@@ -426,6 +426,6 @@ curl https://nis2.agile.software/api/services/status</pre>
</div> </div>
</div> </div>
<script src="js/common.js?v=20260631"></script> <script src="js/common.js?v=20260729b"></script>
</body> </body>
</html> </html>
+169 -150
View File
@@ -3,29 +3,63 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Aggiornamenti Normativi - NIS2 Agile</title> <title>Aggiornamenti Normativi · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
via JS (card aggiornamenti/badge/modali) -> parita' funzionale assoluta. BI veste la chrome. --> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<style> <style>
.impact-badge { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; } /* V3 — specifico pagina Normative (non coperto da v3.css) */
.impact-critical { background: var(--danger-bg); color: var(--danger); } .nrm-bars{display:grid;gap:18px}
.impact-high { background: #fff7ed; color: #c2410c; } .nrm-progress-track{height:8px;background:#EAF0F6;border-radius:99px;overflow:hidden}
.impact-medium { background: var(--warning-bg); color: #a16207; } .nrm-progress-fill{height:100%;background:var(--c-blue);border-radius:99px;transition:width .5s ease}
.impact-low { background: var(--gray-100); color: var(--gray-600); } .nrm-filters{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
.impact-informational { background: rgba(6,182,212,0.1); color: #0891b2; } .nrm-select{padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:#fff;font:inherit;font-size:13px;color:var(--ink)}
.ack-badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 600; } .nrm-check{display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;color:var(--ink)}
.ack-done { background: var(--success-bg); color: var(--success); } .nrm-check input{accent-color:var(--c-blue)}
.ack-pending { background: var(--warning-bg); color: #a16207; } .update-card{border:1px solid var(--line);border-radius:11px;padding:18px;margin-bottom:14px;transition:border-color .12s,box-shadow .12s;background:#fff}
.source-chip { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 700; background: var(--gray-100); color: var(--gray-600); font-family: monospace; } .update-card:hover{border-color:#9FBEDD;box-shadow:0 4px 12px rgba(16,40,80,.08)}
.update-card { border: 1px solid var(--gray-200); border-radius: var(--border-radius-lg); padding: 20px; margin-bottom: 16px; transition: all var(--transition-fast); } .update-card.action-required{border-left:3px solid var(--c-amber)}
.update-card:hover { border-color: var(--primary); box-shadow: 0 2px 8px rgba(6,182,212,0.1); } .update-card.acked{opacity:.72}
.update-card.action-required { border-left: 3px solid var(--warning); } .source-chip{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:10.5px;font-weight:700;background:#EEF2F7;color:#5b6b80;font-family:'Inter',monospace}
.update-card.acked { opacity: 0.7; } .impact-badge{display:inline-flex;align-items:center;padding:2px 9px;border-radius:6px;font-size:10px;font-weight:700;letter-spacing:.03em;text-transform:uppercase}
.ack-progress { height: 6px; background: var(--gray-200); border-radius: 3px; overflow: hidden; } .impact-critical{background:#FBE9E9;color:#B23636}
.ack-progress-bar { height: 100%; background: var(--primary); border-radius: 3px; transition: width 0.5s ease; } .impact-high{background:#FBF1E2;color:#C2410C}
.impact-medium{background:#FBF1E2;color:#9A6418}
.impact-low{background:#EEF2F7;color:#5b6b80}
.impact-informational{background:#E7F0FB;color:#1F5FA8}
.ack-badge{display:inline-flex;align-items:center;gap:5px;padding:2px 9px;border-radius:6px;font-size:10.5px;font-weight:700}
.ack-done{background:#E6F4EC;color:#1E7A4E}
.ack-pending{background:#FBF1E2;color:#9A6418}
.nrm-uupd-h3{font-size:15px;font-weight:700;color:var(--ink);margin:0 0 6px}
.nrm-ref{font-size:11.5px;color:var(--muted);margin:0 0 8px}
.nrm-ref code{background:#EEF2F7;border-radius:5px;padding:1px 6px}
.nrm-summary{font-size:13px;color:var(--muted);line-height:1.6;margin:0 0 10px}
.nrm-card-grid{display:flex;justify-content:space-between;gap:14px;flex-wrap:wrap}
.nrm-card-side{display:flex;flex-direction:column;gap:8px;align-items:flex-end;flex-shrink:0}
.nrm-eff{font-size:11.5px;color:var(--muted)}
.nrm-action-tag{font-size:10px;font-weight:700;color:#9A6418;background:#FBF1E2;padding:2px 8px;border-radius:6px;text-transform:uppercase;letter-spacing:.03em}
/* Modal + toast prodotti da common.js (style.css rimosso): stile minimo coerente V3 */
.modal-overlay{position:fixed;inset:0;background:rgba(15,27,46,.5);display:flex;align-items:flex-start;justify-content:center;z-index:1050;opacity:0;transition:opacity .2s;padding:48px 16px;overflow:auto}
.modal-overlay.active{opacity:1}
.modal-overlay .modal{background:#fff;border-radius:12px;width:100%;max-width:560px;box-shadow:0 20px 50px rgba(11,40,80,.25);transform:translateY(-8px);transition:transform .2s}
.modal-overlay.active .modal{transform:none}
.modal-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--line)}
.modal-header h3{margin:0;font-size:16px;font-weight:700}
.modal-close{background:none;border:0;font-size:1.5rem;line-height:1;cursor:pointer;color:var(--muted)}
.modal-body{padding:18px 20px}
.modal-footer{padding:14px 20px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:10px;flex-wrap:wrap}
.form-group{margin-bottom:14px}
.form-label{display:block;font-weight:600;font-size:13px;margin-bottom:5px}
.form-control{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;font:inherit;font-size:14px}
textarea.form-control{min-height:64px;resize:vertical}
.notification-container{position:fixed;top:18px;right:18px;z-index:1100;display:flex;flex-direction:column;gap:10px}
.notification{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-left:4px solid var(--c-blue);border-radius:10px;box-shadow:0 8px 24px rgba(16,40,80,.14);padding:12px 14px;min-width:240px;max-width:360px;font-size:13px}
.notification.success{border-left-color:var(--c-green)}.notification.error{border-left-color:var(--c-red)}.notification.warning{border-left-color:var(--c-amber)}
.notification-icon{width:18px;height:18px;flex:0 0 auto}.notification-icon svg{width:18px;height:18px}
.notification.success .notification-icon{color:var(--c-green)}.notification.error .notification-icon{color:var(--c-red)}.notification.warning .notification-icon{color:var(--c-amber)}.notification.info .notification-icon{color:var(--c-blue)}
.notification-message{flex:1;min-width:0}
.notification-close{background:none;border:0;font-size:1.2rem;line-height:1;cursor:pointer;color:var(--muted)}
.notification.fade-out{opacity:0;transition:opacity .3s}
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -42,95 +76,82 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content">
<div class="page-header"> <main class="wrap">
<div class="page-header-content"> <nav class="crumb" aria-label="breadcrumb">
<h1 class="page-title">Aggiornamenti Normativi</h1> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<p class="page-subtitle">Feed NIS2 / ACN / DORA — Presa visione obbligatoria per audit compliance</p> <i data-lucide="chevron-right" class="ic"></i><span>Lex e ISO</span>
</div> <i data-lucide="chevron-right" class="ic"></i><span>Normative</span>
<div class="page-header-actions"> </nav>
<button class="btn btn-secondary" onclick="loadPending()"> <div class="page-head">
<svg class="icon icon-sm" aria-hidden="true" style="margin-right:6px;"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-clock"></use></svg> <div>
Solo Pendenti <h1 data-i18n="normative.title">Aggiornamenti Normativi</h1>
</button> <p class="sub">Feed NIS2 / ACN / DORA — Presa visione obbligatoria per audit compliance</p>
</div> </div>
<button class="btn-ghost" onclick="loadPending()"><i data-lucide="clock" class="ic"></i>Solo pendenti</button>
</div>
<div class="stack">
<!-- Stats (dati reali da /normative/stats) -->
<div class="grid3" id="stats-grid">
<section class="card card-pad stat blue"><div style="flex:1;min-width:0"><div class="lbl">Caricamento…</div><div class="val">…</div></div></section>
</div> </div>
<!-- Stats --> <!-- Avanzamento presa visione -->
<div class="stats-grid mb-24" id="stats-grid"> <section class="card card-pad">
<div class="stat-card"><div class="spinner"></div></div> <div class="sec-head"><h3>Avanzamento presa visione</h3><span class="hint" id="ack-pct">—</span></div>
</div> <div class="nrm-progress-track"><div class="nrm-progress-fill" id="ack-bar" style="width:0%"></div></div>
<p class="sub" style="margin:8px 0 0;font-size:12px;color:var(--muted)">Documentare la presa visione degli aggiornamenti normativi è richiesto per la compliance NIS2 Art. 21.</p>
</section>
<!-- Ack Progress bar --> <!-- Filtri -->
<div class="card mb-24"> <section class="card card-pad">
<div class="card-body" style="padding:16px 20px;"> <div class="nrm-filters">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;"> <label for="filter-source" class="visually-hidden" style="position:absolute;width:1px;height:1px;overflow:hidden">Filtra per fonte normativa</label>
<span style="font-size:0.875rem; font-weight:600; color:var(--gray-700);">Avanzamento presa visione</span> <select id="filter-source" class="nrm-select" onchange="loadUpdates()" aria-label="Filtra per fonte normativa">
<span id="ack-pct" style="font-size:0.875rem; font-weight:700; color:var(--primary);">—</span> <option value="">Tutte le fonti</option>
</div> <option value="dlgs_138_2024">D.Lgs. 138/2024</option>
<div class="ack-progress"> <option value="nis2_directive">Direttiva NIS2</option>
<div class="ack-progress-bar" id="ack-bar" style="width:0%"></div> <option value="acn_guideline">ACN Guideline</option>
</div> <option value="dora">DORA</option>
<p style="font-size:0.75rem; color:var(--gray-500); margin-top:6px;">Documentare la presa visione degli aggiornamenti normativi è richiesto per la compliance NIS2 Art.21.</p> <option value="enisa">ENISA</option>
<option value="iso27001">ISO 27001</option>
</select>
<label for="filter-impact" class="visually-hidden" style="position:absolute;width:1px;height:1px;overflow:hidden">Filtra per livello di impatto</label>
<select id="filter-impact" class="nrm-select" onchange="loadUpdates()" aria-label="Filtra per livello di impatto">
<option value="">Tutti i livelli</option>
<option value="critical">Critico</option>
<option value="high">Alto</option>
<option value="medium">Medio</option>
<option value="low">Basso</option>
<option value="informational">Informativo</option>
</select>
<label class="nrm-check"><input type="checkbox" id="filter-action" onchange="loadUpdates()"> Solo azione richiesta</label>
</div> </div>
</div> </section>
<!-- Filters --> <div id="updates-container"><div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div></div>
<div class="card mb-16"> </div>
<div class="card-body" style="padding:14px 20px;"> </main>
<div style="display:flex; gap:12px; flex-wrap:wrap; align-items:center;">
<label for="filter-source" class="visually-hidden">Filtra per fonte normativa</label>
<select id="filter-source" class="form-control" style="width:auto;" onchange="loadUpdates()" aria-label="Filtra per fonte normativa">
<option value="">Tutte le fonti</option>
<option value="dlgs_138_2024">D.Lgs. 138/2024</option>
<option value="nis2_directive">Direttiva NIS2</option>
<option value="acn_guideline">ACN Guideline</option>
<option value="dora">DORA</option>
<option value="enisa">ENISA</option>
<option value="iso27001">ISO 27001</option>
</select>
<label for="filter-impact" class="visually-hidden">Filtra per livello di impatto</label>
<select id="filter-impact" class="form-control" style="width:auto;" onchange="loadUpdates()" aria-label="Filtra per livello di impatto">
<option value="">Tutti i livelli</option>
<option value="critical">Critico</option>
<option value="high">Alto</option>
<option value="medium">Medio</option>
<option value="low">Basso</option>
<option value="informational">Informativo</option>
</select>
<label style="display:flex; align-items:center; gap:8px; font-size:0.875rem; cursor:pointer;">
<input type="checkbox" id="filter-action" onchange="loadUpdates()" style="accent-color:var(--primary);">
Solo azione richiesta
</label>
</div>
</div>
</div>
<div id="updates-container"><div class="spinner" style="margin:60px auto;"></div></div> <!-- Scripts V3 (ordine ESATTO come dashboard.html) -->
</main> <script src="/js/api.js?v=20260730a"></script>
</div> <script src="/js/common.js?v=20260729b"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/api.js?v=20260631"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/common.js?v=20260631"></script> <script src="/js/i18n.js?v=20260731"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <script src="/js/help.js?v=20260806b"></script>
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</script>
<script src="/js/common-bi.js?v=20260631"></script>
<script src="/js/i18n.js?v=20260631"></script>
<script src="/js/help.js?v=20260618p2"></script>
<script> <script>
// ── Auth + chrome + i18n + help ──────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar(); // = loadTopnavV3()
I18n.init(); if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init(); if (typeof HelpSystem !== 'undefined') HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
function refreshIcons() { if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} } }
const sourceLabels = { const sourceLabels = {
nis2_directive: 'NIS2 Directive', dlgs_138_2024: 'D.Lgs. 138/2024', nis2_directive: 'NIS2 Directive', dlgs_138_2024: 'D.Lgs. 138/2024',
@@ -149,30 +170,27 @@
function renderStats(d) { function renderStats(d) {
document.getElementById('stats-grid').innerHTML = ` document.getElementById('stats-grid').innerHTML = `
<div class="stat-card"> <a class="card card-pad stat blue" style="cursor:default">
<div class="stat-label">Aggiornamenti Totali</div> <div class="ibox"><i data-lucide="layers" class="ic"></i></div>
<div class="stat-value">${d.total_updates || 0}</div> <div style="flex:1;min-width:0"><div class="lbl">Aggiornamenti totali</div><div class="val blue">${d.total_updates || 0}</div></div>
</div> </a>
<div class="stat-card"> <a class="card card-pad stat green" style="cursor:default">
<div class="stat-label">Prese Visione</div> <div class="ibox"><i data-lucide="check-check" class="ic"></i></div>
<div class="stat-value" style="color:var(--success);">${d.acknowledged || 0}</div> <div style="flex:1;min-width:0"><div class="lbl">Prese visione</div><div class="val green">${d.acknowledged || 0}</div></div>
</div> </a>
<div class="stat-card"> <a class="card card-pad stat amber" style="cursor:default">
<div class="stat-label">Pendenti</div> <div class="ibox"><i data-lucide="clock" class="ic"></i></div>
<div class="stat-value" style="color:var(--warning);">${d.pending || 0}</div> <div style="flex:1;min-width:0"><div class="lbl">Pendenti</div><div class="val amber">${d.pending || 0}</div><div class="sub">Compliance ACK ${d.ack_rate || 0}%</div></div>
</div> </a>`;
<div class="stat-card">
<div class="stat-label">Compliance ACK</div>
<div class="stat-value" style="color:var(--primary);">${d.ack_rate || 0}%</div>
</div>`;
document.getElementById('ack-pct').textContent = (d.ack_rate || 0) + '%'; document.getElementById('ack-pct').textContent = (d.ack_rate || 0) + '%';
document.getElementById('ack-bar').style.width = (d.ack_rate || 0) + '%'; document.getElementById('ack-bar').style.width = (d.ack_rate || 0) + '%';
refreshIcons();
} }
async function loadUpdates() { async function loadUpdates() {
const container = document.getElementById('updates-container'); const container = document.getElementById('updates-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>'; container.innerHTML = '<div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div>';
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
const source = document.getElementById('filter-source').value; const source = document.getElementById('filter-source').value;
@@ -185,30 +203,36 @@
const result = await api.request('GET', '/normative/list?' + params); const result = await api.request('GET', '/normative/list?' + params);
if (result.success) renderUpdates(result.data.updates || []); if (result.success) renderUpdates(result.data.updates || []);
} catch (e) { } catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore caricamento aggiornamenti</h4></div>'; container.innerHTML = '<div class="v3-error"><i data-lucide="alert-triangle" class="ic"></i><b>Errore di caricamento</b><span>Riprova tra poco.</span></div>';
refreshIcons();
} }
} }
async function loadPending() { async function loadPending() {
const container = document.getElementById('updates-container'); const container = document.getElementById('updates-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>'; container.innerHTML = '<div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div>';
try { try {
const result = await api.request('GET', '/normative/pending'); const result = await api.request('GET', '/normative/pending');
if (result.success) renderUpdates(result.data.pending || [], true); if (result.success) renderUpdates(result.data.pending || [], true);
} catch (e) { container.innerHTML = '<div class="empty-state"><h4>Errore.</h4></div>'; } } catch (e) {
container.innerHTML = '<div class="v3-error"><i data-lucide="alert-triangle" class="ic"></i><b>Errore di caricamento</b><span>Riprova tra poco.</span></div>';
refreshIcons();
}
} }
function renderUpdates(updates, pendingOnly) { function renderUpdates(updates, pendingOnly) {
const container = document.getElementById('updates-container'); const container = document.getElementById('updates-container');
if (!updates.length) { if (!updates.length) {
container.innerHTML = `<div class="empty-state"> container.innerHTML = `<section class="card card-pad"><div class="v3-empty">
<svg class="icon" aria-hidden="true" style="width:40px;height:40px;color:var(--gray-300)"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-file"></use></svg> <i data-lucide="file-text" class="ic"></i>
<h4>${pendingOnly ? 'Nessun aggiornamento pendente — tutto preso in visione!' : 'Nessun aggiornamento trovato'}</h4> <b>${pendingOnly ? 'Nessun aggiornamento pendente — tutto preso in visione!' : 'Nessun aggiornamento trovato'}</b>
</div>`; <span>I prossimi aggiornamenti normativi appariranno qui.</span>
</div></section>`;
refreshIcons();
return; return;
} }
let html = ''; let html = '<div class="stack">';
updates.forEach(u => { updates.forEach(u => {
const acked = u.is_acknowledged || !!(u.acknowledged_at); const acked = u.is_acknowledged || !!(u.acknowledged_at);
const actionClass = u.action_required ? 'action-required' : ''; const actionClass = u.action_required ? 'action-required' : '';
@@ -218,51 +242,46 @@
).join(' '); ).join(' ');
html += `<div class="update-card ${actionClass} ${ackedClass}" id="update-${u.id}"> html += `<div class="update-card ${actionClass} ${ackedClass}" id="update-${u.id}">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;"> <div class="nrm-card-grid">
<div style="flex:1;"> <div style="flex:1;min-width:0;">
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:8px;"> <div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px;">
<span class="source-chip">${escapeHtml(sourceLabels[u.source] || u.source)}</span> <span class="source-chip">${escapeHtml(sourceLabels[u.source] || u.source)}</span>
<span class="impact-badge impact-${u.impact_level}">${escapeHtml(impactLabels[u.impact_level] || u.impact_level)}</span> <span class="impact-badge impact-${u.impact_level}">${escapeHtml(impactLabels[u.impact_level] || u.impact_level)}</span>
${u.action_required ? '<span style="font-size:0.7rem; font-weight:700; color:var(--warning); background:var(--warning-bg); padding:2px 8px; border-radius:4px;">Azione richiesta</span>' : ''} ${u.action_required ? '<span class="nrm-action-tag">Azione richiesta</span>' : ''}
${acked ${acked
? `<span class="ack-badge ack-done"><svg class="icon" aria-hidden="true" style="width:11px;height:11px;fill:currentColor;"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>Preso in visione ${u.ack_by_name ? 'da ' + escapeHtml(u.ack_by_name) : ''}</span>` ? `<span class="ack-badge ack-done"><i data-lucide="check" style="width:12px;height:12px"></i>Preso in visione ${u.ack_by_name ? 'da ' + escapeHtml(u.ack_by_name) : ''}</span>`
: '<span class="ack-badge ack-pending">Da prendere in visione</span>' : '<span class="ack-badge ack-pending">Da prendere in visione</span>'
} }
</div> </div>
<h3 style="font-size:1rem; font-weight:700; color:var(--gray-900); margin-bottom:6px;">${escapeHtml(u.title)}</h3> <h3 class="nrm-uupd-h3">${escapeHtml(u.title)}</h3>
${u.reference ? `<p style="font-size:0.75rem; color:var(--gray-500); margin-bottom:8px;"><code>${escapeHtml(u.reference)}</code></p>` : ''} ${u.reference ? `<p class="nrm-ref"><code>${escapeHtml(u.reference)}</code></p>` : ''}
<p style="font-size:0.8125rem; color:var(--gray-600); line-height:1.6; margin-bottom:10px;">${escapeHtml(u.summary)}</p> <p class="nrm-summary">${escapeHtml(u.summary)}</p>
${domains ? `<div style="display:flex; gap:6px; flex-wrap:wrap;">${domains}</div>` : ''} ${domains ? `<div style="display:flex; gap:6px; flex-wrap:wrap;">${domains}</div>` : ''}
</div> </div>
<div style="display:flex; flex-direction:column; gap:8px; align-items:flex-end; flex-shrink:0;"> <div class="nrm-card-side">
${u.effective_date ? `<p style="font-size:0.75rem; color:var(--gray-500);">In vigore: <strong>${formatDate(u.effective_date)}</strong></p>` : ''} ${u.effective_date ? `<p class="nrm-eff">In vigore: <strong>${formatDate(u.effective_date)}</strong></p>` : ''}
${u.url ? `<a href="${escapeHtml(u.url)}" target="_blank" rel="noopener" class="btn btn-sm btn-secondary" style="font-size:0.75rem;"> ${u.url ? `<a href="${escapeHtml(u.url)}" target="_blank" rel="noopener" class="btn-ghost"><i data-lucide="external-link" class="ic"></i>Fonte</a>` : ''}
<svg class="icon" aria-hidden="true" style="width:12px;height:12px;fill:currentColor;margin-right:4px;"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-external-link"></use></svg> ${!acked ? `<button class="btn-primary" onclick="acknowledgeUpdate(${u.id})"><i data-lucide="check" class="ic"></i>Prendi in visione</button>` : ''}
Fonte
</a>` : ''}
${!acked ? `<button class="btn btn-sm btn-primary" onclick="acknowledgeUpdate(${u.id})">
<svg class="icon" aria-hidden="true" style="width:14px;height:14px;fill:currentColor;margin-right:4px;"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check"></use></svg>
Prendi in visione
</button>` : ''}
</div> </div>
</div> </div>
</div>`; </div>`;
}); });
html += '</div>';
container.innerHTML = html; container.innerHTML = html;
refreshIcons();
} }
async function acknowledgeUpdate(id) { async function acknowledgeUpdate(id) {
showModal('Presa Visione', ` showModal('Presa Visione', `
<p style="font-size:0.875rem; color:var(--gray-600); margin-bottom:16px;"> <p style="font-size:13px; color:var(--muted); margin-bottom:16px;">
Documentare la presa visione dell'aggiornamento normativo. Opzionalmente aggiungere note sull'impatto per la tua organizzazione. Documentare la presa visione dell'aggiornamento normativo. Opzionalmente aggiungere note sull'impatto per la tua organizzazione.
</p> </p>
<div class="form-group"> <div class="form-group">
<label for="ack-notes" class="form-label">Note (opzionale)</label> <label for="ack-notes" class="form-label">Note (opzionale)</label>
<textarea id="ack-notes" class="form-control" rows="3" placeholder="Es. Analizzato con il team di compliance. Nessun impatto diretto rilevato..."></textarea> <textarea id="ack-notes" class="form-control" rows="3" placeholder="Es. Analizzato con il team di compliance. Nessun impatto diretto rilevato..."></textarea>
</div> </div>
`, `<button class="btn btn-primary" onclick="confirmAck(${id})">Conferma presa visione</button> `, { footer: `<button class="btn-primary" onclick="confirmAck(${id})">Conferma presa visione</button>
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>`); <button class="btn-ghost" onclick="closeModal()">Annulla</button>` });
} }
async function confirmAck(id) { async function confirmAck(id) {
@@ -278,7 +297,7 @@
} catch (e) { showNotification('Errore di connessione.', 'error'); } } catch (e) { showNotification('Errore di connessione.', 'error'); }
} }
// Init // ── Init dati reali ──────────────────────────────────────
loadStats(); loadStats();
loadUpdates(); loadUpdates();
</script> </script>
+2 -2
View File
@@ -494,8 +494,8 @@
</script> </script>
<!-- Stessa logica della onboarding.html: ZERO modifiche backend --> <!-- Stessa logica della onboarding.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260631"></script> <script src="js/api.js?v=20260730a"></script>
<script src="js/common.js?v=20260631"></script> <script src="js/common.js?v=20260729b"></script>
<script> <script>
// ── Auth check ────────────────────────────────────────────────── // ── Auth check ──────────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+98 -56
View File
@@ -3,50 +3,50 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Organigramma - NIS2 Agile</title> <title>Organigramma · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. --> <!-- UI V3: Inter self-host + design system v3.css. No external CDN, no Bootstrap Italia. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/style.css?v=20260618"> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<style> <style>
.org-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:18px; font-size:.92rem; line-height:1.6; } /* V3+ page-specific (componenti non coperti da v3.css): albero + modale ruolo. Token V3. */
.org-gov-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:12px 0 18px; } .org-intro { background:#EFF4FB; border-left:4px solid var(--c-blue); padding:14px 18px; border-radius:10px; margin-bottom:14px; font-size:13.5px; line-height:1.6; color:var(--ink); }
.org-stats { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:18px; } .org-gov-note { font-size:12.5px; color:#9A6418; background:#FBF1E2; border:1px solid #f3d9a8; border-radius:10px; padding:10px 12px; margin:0 0 18px; }
.org-stat { flex:1 1 150px; min-width:130px; background:#fff; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; padding:12px 14px; } .org-stats { display:flex; flex-wrap:wrap; gap:12px; margin-bottom:18px; }
.org-stat .n { font-size:1.5rem; font-weight:800; color:var(--primary,#2563eb); line-height:1; } .org-stat { flex:1 1 150px; min-width:130px; background:#fff; border:1px solid var(--line); border-radius:11px; padding:13px 15px; }
.org-stat .l { font-size:.78rem; color:var(--gray-500,#6b7280); margin-top:4px; } .org-stat .n { font-size:26px; font-weight:800; color:var(--c-blue); line-height:1; }
.org-stat .l { font-size:11.5px; color:var(--muted); margin-top:5px; }
/* Albero */ /* Albero */
.org-tree { margin-top:6px; } .org-tree { margin-top:6px; }
.org-children { margin-left:26px; border-left:2px solid var(--gray-100,#eef0f3); padding-left:16px; } .org-children { margin-left:26px; border-left:2px solid var(--line); padding-left:16px; }
.org-node { margin:8px 0; } .org-node { margin:8px 0; }
.org-card { display:flex; align-items:flex-start; gap:12px; background:#fff; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; padding:12px 14px; } .org-card { display:flex; align-items:flex-start; gap:12px; background:#fff; border:1px solid var(--line); border-radius:11px; padding:13px 15px; }
.org-card.gov { border-color:#b45309; background:#fffdf7; } .org-card.gov { border-color:#e0b25a; background:#FFFDF7; }
.org-card-main { flex:1 1 auto; min-width:0; } .org-card-main { flex:1 1 auto; min-width:0; }
.org-role-name { font-weight:700; font-size:.98rem; } .org-role-name { font-weight:700; font-size:14px; }
.org-holder { font-size:.85rem; color:var(--gray-600,#4b5563); margin-top:2px; } .org-holder { font-size:12.5px; color:var(--muted); margin-top:3px; }
.org-holder.vacant { color:#9ca3af; font-style:italic; } .org-holder.vacant { color:#9aa8ba; font-style:italic; }
.org-desc { font-size:.82rem; color:var(--gray-500,#6b7280); margin-top:6px; line-height:1.5; } .org-desc { font-size:12.5px; color:var(--muted); margin-top:6px; line-height:1.5; }
.org-badge { display:inline-block; font-size:.68rem; font-weight:700; padding:2px 8px; border-radius:6px; margin-left:6px; vertical-align:middle; } .org-badge { display:inline-block; font-size:10px; font-weight:700; padding:2px 8px; border-radius:6px; margin-left:6px; vertical-align:middle; }
.org-badge.gov { background:#fef3c7; color:#92400e; } .org-badge.gov { background:#FBF1E2; color:#9A6418; }
.org-actions { display:flex; gap:6px; flex-wrap:wrap; flex-shrink:0; } .org-actions { display:flex; gap:6px; flex-wrap:wrap; flex-shrink:0; }
.org-actions .btn { padding:4px 8px; font-size:.74rem; } .org-empty { text-align:center; padding:48px 16px; color:var(--muted); }
.org-empty { text-align:center; padding:48px 16px; color:var(--gray-500,#6b7280); } .org-empty .big { font-size:16px; font-weight:700; color:var(--ink); margin-bottom:6px; }
.org-empty .big { font-size:1.05rem; font-weight:700; color:var(--gray-700,#374151); margin-bottom:6px; }
/* Modale */ /* Modale */
.om-overlay { display:none; position:fixed; inset:0; background:rgba(15,23,42,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 16px; } .om-overlay { display:none; position:fixed; inset:0; background:rgba(15,38,60,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 16px; }
.om-overlay.open { display:flex; } .om-overlay.open { display:flex; }
.om-dialog { background:#fff; border-radius:12px; width:100%; max-width:560px; box-shadow:0 20px 50px rgba(0,0,0,.25); } .om-dialog { background:#fff; border-radius:14px; width:100%; max-width:560px; box-shadow:0 20px 50px rgba(11,40,80,.25); }
.om-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); } .om-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--line); }
.om-head h3 { margin:0; font-size:1.05rem; } .om-head h3 { margin:0; font-size:17px; font-weight:800; }
.om-body { padding:18px 20px; } .om-body { padding:18px 20px; }
.om-foot { padding:14px 20px; border-top:1px solid var(--gray-100,#f3f4f6); display:flex; justify-content:flex-end; gap:10px; } .om-foot { padding:14px 20px; border-top:1px solid var(--line); display:flex; justify-content:flex-end; gap:10px; }
.om-field { margin-bottom:14px; } .om-field { margin-bottom:14px; }
.om-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; } .om-field label { display:block; font-weight:600; font-size:12.5px; margin-bottom:5px; }
.om-field input[type=text], .om-field select, .om-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; } .om-field input[type=text], .om-field select, .om-field textarea { width:100%; padding:9px 11px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:13.5px; background:#fff; color:var(--ink); }
.om-field textarea { min-height:70px; resize:vertical; } .om-field textarea { min-height:70px; resize:vertical; }
.om-check { display:flex; align-items:flex-start; gap:8px; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; font-size:.85rem; } .om-check { display:flex; align-items:flex-start; gap:8px; background:#FBF1E2; border:1px solid #f3d9a8; border-radius:8px; padding:10px 12px; font-size:12.5px; color:var(--ink); }
.om-check input { margin-top:3px; } .om-check input { margin-top:3px; }
.om-err { color:#b91c1c; font-size:.82rem; min-height:18px; margin-top:2px; } .om-err { color:var(--c-red); font-size:12.5px; min-height:18px; margin-top:2px; }
.om-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); } .om-close { background:none; border:none; font-size:22px; line-height:1; cursor:pointer; color:#9aa8ba; }
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -63,17 +63,27 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="org.title">Organigramma</h2>
<div class="content-header-actions">
<button class="btn btn-primary btn-sm" id="org-btn-new" onclick="orgOpenModal()" data-i18n="org.new_role">Nuovo ruolo</button>
</div>
</header>
<div class="content-body"> <main class="wrap">
<nav class="crumb" aria-label="breadcrumb">
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<i data-lucide="chevron-right" class="ic"></i><span>Struttura interna</span>
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="org.title">Organigramma</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="org.title">Organigramma</h1>
<p class="sub">Ruoli, gerarchia e titolari — base del modello relazionale NIS2</p>
</div>
<button class="btn-primary" id="org-btn-new" onclick="orgOpenModal()">
<i data-lucide="plus" class="ic"></i><span data-i18n="org.new_role">Nuovo ruolo</span>
</button>
</div>
<div class="stack">
<section class="card card-pad">
<div class="org-intro"> <div class="org-intro">
<strong>Chi è responsabile di cosa.</strong> <strong>Chi è responsabile di cosa.</strong>
L'organigramma mappa i ruoli dell'organizzazione, la loro gerarchia e il titolare di ciascuno. È la base del modello relazionale NIS2: nelle fasi successive i ruoli si collegheranno a competenze, procedure, inventario e rischi. L'organigramma mappa i ruoli dell'organizzazione, la loro gerarchia e il titolare di ciascuno. È la base del modello relazionale NIS2: nelle fasi successive i ruoli si collegheranno a competenze, procedure, inventario e rischi.
@@ -93,12 +103,12 @@
<div id="org-empty" class="org-empty" style="display:none;"> <div id="org-empty" class="org-empty" style="display:none;">
<div class="big">Nessun ruolo definito</div> <div class="big">Nessun ruolo definito</div>
<div style="margin-bottom:16px;">Inizia a costruire l'organigramma della tua organizzazione.</div> <div style="margin-bottom:16px;">Inizia a costruire l'organigramma della tua organizzazione.</div>
<button class="btn btn-primary" onclick="orgOpenModal()">Crea il primo ruolo</button> <button class="btn-primary" onclick="orgOpenModal()"><i data-lucide="plus" class="ic"></i>Crea il primo ruolo</button>
<button class="btn btn-outline" onclick="orgScaffold()" style="margin-left:8px;">Crea struttura di base</button> <button class="btn-ghost" onclick="orgScaffold()" style="margin-left:8px;"><i data-lucide="network" class="ic"></i>Crea struttura di base</button>
</div> </div>
</div> </section>
</main> </div>
</div> </main>
<!-- Modale crea/modifica ruolo --> <!-- Modale crea/modifica ruolo -->
<div class="om-overlay" id="org-modal" role="dialog" aria-modal="true" aria-labelledby="org-modal-title"> <div class="om-overlay" id="org-modal" role="dialog" aria-modal="true" aria-labelledby="org-modal-title">
@@ -132,18 +142,50 @@
<div class="om-err" id="org-f-err" role="alert"></div> <div class="om-err" id="org-f-err" role="alert"></div>
</div> </div>
<div class="om-foot"> <div class="om-foot">
<button class="btn btn-outline" type="button" onclick="orgCloseModal()">Annulla</button> <button class="btn-ghost" type="button" onclick="orgCloseModal()">Annulla</button>
<button class="btn btn-primary" type="button" id="org-f-save" onclick="orgSaveRole()">Salva</button> <button class="btn-primary" type="button" id="org-f-save" onclick="orgSaveRole()">Salva</button>
</div> </div>
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="/js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → organigramma.js.
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> organigramma.js si auto-inizializza su DOMContentLoaded (checkAuth/loadSidebar/I18n/Help). -->
<script src="/js/common-bi.js?v=20260631"></script> <script src="/js/api.js?v=20260730a"></script>
<script src="/js/i18n.js?v=20260631"></script> <script src="/js/common.js?v=20260729b"></script>
<script src="/js/help.js?v=20260618p2"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/organigramma.js?v=20260617"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script src="/js/organigramma.js?v=20260806"></script>
<script>
// Render icone Lucide (chrome + crumb + bottoni). organigramma.js fa render albero
// dopo il fetch dati: re-render icone su mutazioni del DOM dell'app.
function v3Icons() { if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} } }
v3Icons();
document.addEventListener('DOMContentLoaded', function () {
v3Icons();
var root = document.querySelector('main.wrap');
if (root && window.MutationObserver) {
// NB: createIcons() sostituisce <i data-lucide> con <svg> → è esso stesso una
// mutazione DOM. Osservare "ogni mutazione" e ri-chiamare createIcons può
// auto-ritriggerarsi in loop (freeze). Quindi: debounce + DISCONNECT durante il
// render, e ignora le mutazioni prodotte da noi stessi (flag applying).
var applying = false, timer = null;
var mo = new MutationObserver(function () {
if (applying) return;
clearTimeout(timer);
timer = setTimeout(function () {
applying = true;
mo.disconnect();
v3Icons();
mo.observe(root, { childList: true, subtree: true });
applying = false;
}, 120);
});
mo.observe(root, { childList: true, subtree: true });
}
});
</script>
</body> </body>
</html> </html>
+36 -36
View File
@@ -3,12 +3,12 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Policy - NIS2 Agile</title> <title>Policy · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css (self-host only, nessun CDN esterno). -->
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
via JS (tabelle/editor/modali) -> parita' funzionale assoluta. BI veste la chrome. --> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <!-- style.css resta caricato DOPO v3.css: veste i widget renderizzati via JS
<link rel="stylesheet" href="/css/style.css?v=20260618"> (tabelle/editor/modali/badge/tab). Self-hostato (nessun CDN). v3.css governa chrome + shell. -->
<style> <style>
/* ── Policy Page Styles ──────────────────────────────────── */ /* ── Policy Page Styles ──────────────────────────────────── */
.filters-bar { .filters-bar {
@@ -254,20 +254,26 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content"> <main class="wrap">
<header class="content-header"> <nav class="crumb" aria-label="breadcrumb">
<h1 class="h2" data-i18n="policies.title">Gestione Policy</h1> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<div class="content-header-actions"> <i data-lucide="chevron-right" class="ic"></i><span data-i18n="policies.title">Gestione Policy</span>
<button id="nis2-policies-btn-ai-generate" class="btn btn-outline" onclick="openAIGenerateModal()"> </nav>
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg> <div class="page-head">
Genera con AI <div>
</button> <h1 data-i18n="policies.title">Gestione Policy</h1>
<button id="nis2-policies-btn-new" class="btn btn-primary" onclick="openCreateModal()">+ Nuova Policy</button> <p class="sub">Politiche di sicurezza: bozze, revisione, approvazione e template NIS2</p>
</div> </div>
</header> <div class="content-header-actions" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<button id="nis2-policies-btn-ai-generate" class="btn-ghost" onclick="openAIGenerateModal()">
<i data-lucide="sparkles" class="ic"></i>Genera con AI
</button>
<button id="nis2-policies-btn-new" class="btn-primary" onclick="openCreateModal()"><i data-lucide="plus" class="ic"></i>Nuova Policy</button>
</div>
</div>
<div class="content-body" id="page-content"> <div class="content-body" id="page-content">
<!-- Tabs --> <!-- Tabs -->
@@ -330,29 +336,23 @@
<!-- Detail View (hidden by default) --> <!-- Detail View (hidden by default) -->
<div id="policy-detail-view" style="display:none;"></div> <div id="policy-detail-view" style="display:none;"></div>
</div> </div>
</main> </main>
</div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="/js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → pagina -->
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <script src="/js/api.js?v=20260730a"></script>
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, <script src="/js/common.js?v=20260729b"></script>
riusando tutti gli helper di common.js (zero backend). --> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script> <script src="/js/i18n.js?v=20260731"></script>
if (window.bootstrap && bootstrap.loadFonts) { <script src="/js/help.js?v=20260806b"></script>
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
</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> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
I18n.init(); I18n.init();
HelpSystem.init(); HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
// ── Labels ────────────────────────────────────────────── // ── Labels ──────────────────────────────────────────────
const CATEGORY_LABELS = { const CATEGORY_LABELS = {
+88 -56
View File
@@ -3,52 +3,54 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Matrice RACI - NIS2 Agile</title> <title>Matrice RACI · NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <!-- UI V3: Inter self-host + design system v3.css. No external CDN, no Bootstrap Italia. -->
<link rel="stylesheet" href="/css/style.css?v=20260618"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style> <style>
.raci-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:16px; font-size:.92rem; line-height:1.6; } /* V3+ page-specific (componenti non coperti da v3.css): tab interni, matrice RACI, link m2m. Token V3. */
.raci-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 18px; } .raci-intro { background:#EFF4FB; border-left:4px solid var(--c-blue); padding:14px 18px; border-radius:10px; margin-bottom:14px; font-size:13.5px; line-height:1.6; color:var(--ink); }
.raci-note { font-size:12.5px; color:#9A6418; background:#FBF1E2; border:1px solid #f3d9a8; border-radius:10px; padding:10px 12px; margin:0 0 18px; }
.cmp-tabs { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:18px; } .cmp-tabs { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:18px; }
.cmp-tab { flex:1 1 200px; min-width:160px; padding:10px 12px; border:1.5px solid var(--gray-200,#e5e7eb); border-radius:10px; background:#fff; cursor:pointer; font-size:.85rem; font-weight:600; text-align:center; } .cmp-tab { flex:1 1 200px; min-width:160px; padding:11px 12px; border:1.5px solid var(--line); border-radius:10px; background:#fff; cursor:pointer; font:inherit; font-size:13px; font-weight:600; text-align:center; color:var(--ink); }
.cmp-tab.active { border-color:var(--primary,#2563eb); background:#eff6ff; color:var(--primary,#2563eb); } .cmp-tab.active { border-color:var(--c-blue); background:#EFF4FB; color:var(--c-blue); }
.cmp-panel { display:none; } .cmp-panel { display:none; }
.cmp-panel.active { display:block; } .cmp-panel.active { display:block; }
.cmp-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; } .cmp-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.cmp-toolbar select { padding:8px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.88rem; } .cmp-toolbar select { padding:8px 10px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:13.5px; background:#fff; color:var(--ink); }
.cmp-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); } .cmp-empty { text-align:center; padding:34px 16px; color:var(--muted); }
.gap-role-sub { font-size:.82rem; color:var(--gray-500,#6b7280); } .gap-role-sub { font-size:12px; color:var(--muted); }
/* Matrice RACI */ /* Matrice RACI */
.raci-matrix-wrap { overflow-x:auto; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; } .raci-matrix-wrap { overflow-x:auto; border:1px solid var(--line); border-radius:11px; }
.raci-matrix { border-collapse:collapse; font-size:.84rem; min-width:100%; } .raci-matrix { border-collapse:collapse; font-size:13px; min-width:100%; }
.raci-matrix th, .raci-matrix td { padding:8px 10px; border-bottom:1px solid var(--gray-100,#f3f4f6); border-right:1px solid var(--gray-100,#f3f4f6); text-align:center; vertical-align:middle; } .raci-matrix th, .raci-matrix td { padding:9px 11px; border-bottom:1px solid var(--line); border-right:1px solid var(--line); text-align:center; vertical-align:middle; }
.raci-matrix thead th { background:var(--gray-50,#f9fafb); font-size:.72rem; text-transform:uppercase; letter-spacing:.03em; color:var(--gray-500,#6b7280); position:sticky; top:0; } .raci-matrix thead th { background:#FAFCFE; font-size:10.5px; text-transform:uppercase; letter-spacing:.03em; color:var(--muted); font-weight:700; position:sticky; top:0; }
.raci-matrix thead th.raci-grp { text-align:left; } .raci-matrix thead th.raci-grp { text-align:left; }
.raci-matrix th.raci-rolehead { text-align:left; font-weight:700; color:var(--gray-700,#374151); background:#fff; position:sticky; left:0; z-index:2; min-width:180px; } .raci-matrix th.raci-rolehead { text-align:left; font-weight:700; color:var(--ink); background:#fff; position:sticky; left:0; z-index:2; min-width:180px; }
.raci-matrix thead th.raci-corner { position:sticky; left:0; z-index:3; background:var(--gray-50,#f9fafb); text-align:left; } .raci-matrix thead th.raci-corner { position:sticky; left:0; z-index:3; background:#FAFCFE; text-align:left; }
.raci-objhead { font-weight:600; min-width:130px; } .raci-objhead { font-weight:600; min-width:130px; }
.raci-objhead .raci-objtype { display:block; font-size:.66rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-400,#9ca3af); } .raci-objhead .raci-objtype { display:block; font-size:9.5px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:#9aa8ba; }
.raci-cell-sel { width:100%; padding:5px 6px; border:1px solid var(--gray-200,#e5e7eb); border-radius:6px; font-size:.82rem; background:#fff; cursor:pointer; } .raci-cell-sel { width:100%; padding:6px 7px; border:1px solid var(--line); border-radius:6px; font:inherit; font-size:12.5px; background:#fff; color:var(--ink); cursor:pointer; }
.raci-cell-sel.has-r { background:#dcfce7; border-color:#86efac; color:#166534; font-weight:700; } .raci-cell-sel.has-r { background:#E6F4EC; border-color:#86efac; color:#166534; font-weight:700; }
.raci-cell-sel.has-a { background:#fee2e2; border-color:#fca5a5; color:#991b1b; font-weight:700; } .raci-cell-sel.has-a { background:#FBE9E9; border-color:#fca5a5; color:#991b1b; font-weight:700; }
.raci-cell-sel.has-c { background:#fef9c3; border-color:#fde047; color:#854d0e; font-weight:700; } .raci-cell-sel.has-c { background:#FEF9C3; border-color:#fde047; color:#854d0e; font-weight:700; }
.raci-cell-sel.has-i { background:#e0f2fe; border-color:#7dd3fc; color:#075985; font-weight:700; } .raci-cell-sel.has-i { background:#E0F2FE; border-color:#7dd3fc; color:#075985; font-weight:700; }
.raci-legend { display:flex; gap:14px; flex-wrap:wrap; font-size:.78rem; margin:12px 2px 0; color:var(--gray-600,#4b5563); } .raci-legend { display:flex; gap:14px; flex-wrap:wrap; font-size:12px; margin:14px 2px 0; color:var(--muted); }
.raci-legend span b { display:inline-block; width:18px; text-align:center; border-radius:4px; margin-right:4px; } .raci-legend span b { display:inline-block; width:18px; text-align:center; border-radius:4px; margin-right:4px; }
.raci-legend .l-r b { background:#dcfce7; color:#166534; } .raci-legend .l-r b { background:#E6F4EC; color:#166534; }
.raci-legend .l-a b { background:#fee2e2; color:#991b1b; } .raci-legend .l-a b { background:#FBE9E9; color:#991b1b; }
.raci-legend .l-c b { background:#fef9c3; color:#854d0e; } .raci-legend .l-c b { background:#FEF9C3; color:#854d0e; }
.raci-legend .l-i b { background:#e0f2fe; color:#075985; } .raci-legend .l-i b { background:#E0F2FE; color:#075985; }
/* Link m2m */ /* Link m2m */
.raci-inline { display:flex; gap:8px; flex-wrap:wrap; align-items:flex-end; background:#f9fafb; border:1px solid var(--gray-100,#f3f4f6); border-radius:10px; padding:12px; margin-bottom:14px; } .raci-inline { display:flex; gap:8px; flex-wrap:wrap; align-items:flex-end; background:#FAFCFE; border:1px solid var(--line); border-radius:10px; padding:12px; margin-bottom:14px; }
.raci-inline .fld { display:flex; flex-direction:column; gap:3px; } .raci-inline .fld { display:flex; flex-direction:column; gap:3px; }
.raci-inline .fld label { font-size:.72rem; font-weight:600; color:var(--gray-500,#6b7280); } .raci-inline .fld label { font-size:11px; font-weight:700; color:var(--muted); }
.raci-inline select { padding:8px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.88rem; min-width:200px; } .raci-inline select { padding:8px 10px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:13.5px; background:#fff; color:var(--ink); min-width:200px; }
.raci-linklist { width:100%; border-collapse:collapse; font-size:.88rem; } .raci-linklist { width:100%; border-collapse:collapse; font-size:13.5px; }
.raci-linklist th, .raci-linklist td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--gray-100,#f3f4f6); } .raci-linklist th, .raci-linklist td { text-align:left; padding:10px 11px; border-bottom:1px solid var(--line); }
.raci-linklist th { font-size:.74rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); } .raci-linklist th { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); font-weight:700; }
.raci-linklist .btn { padding:4px 9px; font-size:.76rem; } .raci-arrow { color:#9aa8ba; padding:0 6px; }
.raci-arrow { color:var(--gray-400,#9ca3af); padding:0 6px; } .savehint { font-size:12px; color:var(--muted); }
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -65,17 +67,24 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <div id="topnav"></div>
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="raci.title">Matrice RACI</h2>
<div class="content-header-actions">
<span class="savehint" id="raci-hint" role="status" aria-live="polite"></span>
</div>
</header>
<div class="content-body"> <main class="wrap">
<nav class="crumb" aria-label="breadcrumb">
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<i data-lucide="chevron-right" class="ic"></i><span>Struttura interna</span>
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="raci.title">Matrice RACI</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="raci.title">Matrice RACI</h1>
<p class="sub">Responsabilità R/A/C/I dei ruoli e collegamenti tra oggetti</p>
</div>
<span class="savehint" id="raci-hint" role="status" aria-live="polite"></span>
</div>
<div class="stack">
<section class="card card-pad">
<div class="raci-intro"> <div class="raci-intro">
<strong>Matrice RACI &amp; collegamenti.</strong> <strong>Matrice RACI &amp; collegamenti.</strong>
Collega i ruoli dell'organigramma agli oggetti del sistema (inventario, procedure, rischi, fornitori) assegnando le responsabilità <strong>R</strong>esponsible / <strong>A</strong>ccountable / <strong>C</strong>onsulted / <strong>I</strong>nformed. Gestisci inoltre i collegamenti molti-a-molti fra procedure, inventario, rischi e misure. Collega i ruoli dell'organigramma agli oggetti del sistema (inventario, procedure, rischi, fornitori) assegnando le responsabilità <strong>R</strong>esponsible / <strong>A</strong>ccountable / <strong>C</strong>onsulted / <strong>I</strong>nformed. Gestisci inoltre i collegamenti molti-a-molti fra procedure, inventario, rischi e misure.
@@ -123,20 +132,43 @@
<label id="raci-blabel" for="raci-bside">Lato B</label> <label id="raci-blabel" for="raci-bside">Lato B</label>
<select id="raci-bside"></select> <select id="raci-bside"></select>
</div> </div>
<button class="btn btn-primary btn-sm" onclick="raciAddLink()">Aggiungi collegamento</button> <button class="btn-primary" onclick="raciAddLink()">Aggiungi collegamento</button>
</div> </div>
<div id="raci-links"></div> <div id="raci-links"></div>
</div> </div>
</div> </section>
</main> </div>
</div> </main>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3. raci.js si auto-inizializza su DOMContentLoaded (checkAuth/loadSidebar/I18n/Help). -->
<script src="/js/common.js?v=20260631"></script> <script src="/js/api.js?v=20260730a"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/common.js?v=20260729b"></script>
<script src="/js/common-bi.js?v=20260631"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/i18n.js?v=20260631"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/help.js?v=20260618p2"></script> <script src="/js/i18n.js?v=20260731"></script>
<script src="/js/raci.js?v=20260619"></script> <script src="/js/help.js?v=20260806b"></script>
<script src="/js/raci.js?v=20260627"></script>
<script>
function v3Icons() { if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} } }
v3Icons();
document.addEventListener('DOMContentLoaded', function () {
v3Icons();
var root = document.querySelector('main.wrap');
if (root && window.MutationObserver) {
// Anti-loop: createIcons() muta il DOM (<i>->svg) → osservare "ogni mutazione"
// e ri-chiamarlo puo' auto-ritriggerarsi (freeze). Debounce + disconnect.
var applying = false, timer = null;
var mo = new MutationObserver(function () {
if (applying) return;
clearTimeout(timer);
timer = setTimeout(function () {
applying = true; mo.disconnect(); v3Icons();
mo.observe(root, { childList: true, subtree: true }); applying = false;
}, 120);
});
mo.observe(root, { childList: true, subtree: true });
}
});
</script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -268,8 +268,8 @@
</script> </script>
<!-- Stessa logica della register.html: ZERO modifiche backend --> <!-- Stessa logica della register.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260631"></script> <script src="js/api.js?v=20260730a"></script>
<script src="js/common.js?v=20260631"></script> <script src="js/common.js?v=20260729b"></script>
<script> <script>
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; } if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
+197 -418
View File
@@ -3,344 +3,127 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Audit e Report - NIS2 Agile</title> <title>Audit e Report · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css <!-- UI V3: Inter self-host + design system v3.css (self-host only, nessun CDN esterno). -->
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
via JS (tabelle/modali/form) -> parita' funzionale assoluta. BI veste la chrome. --> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<style> <style>
/* ── Compat V3: token + widget emessi dal JS (ex style.css, niente CDN) ───── */
:root{
--primary:#1F5FA8;--primary-bg:#EAF1FA;
--secondary:#16794A;--secondary-bg:#E6F4EC;
--warning:#B45309;--danger:#B42318;--info:#1F5FA8;--info-bg:#EAF1FA;
--gray-50:#F8FAFC;--gray-100:#F1F5F9;--gray-200:#E3E9F0;--gray-300:#CBD5E1;
--gray-400:#94A3B8;--gray-500:#64748B;--gray-600:#475569;--gray-700:#334155;--gray-800:#1E293B;
--card-bg:#fff;--border-radius:10px;--border-radius-sm:7px;
--card-shadow:0 1px 2px rgba(16,40,80,.06),0 1px 3px rgba(16,40,80,.05);
--transition-fast:.12s ease;--font-mono:ui-monospace,SFMono-Regular,Menlo,monospace;
}
/* Bottoni emessi dal JS (modali) — coerenti con v3.css */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:7px;border:1px solid transparent;border-radius:8px;padding:8px 14px;font:inherit;font-weight:600;font-size:13px;cursor:pointer;line-height:1.2;text-decoration:none}
.btn:disabled{opacity:.55;cursor:not-allowed}
.btn-primary{background:var(--c-blue,#1F5FA8);color:#fff}.btn-primary:hover:not(:disabled){background:#1A5494}
.btn-secondary{background:#fff;color:var(--ink,#1E293B);border-color:var(--line,#E3E9F0)}.btn-secondary:hover:not(:disabled){background:#F5F8FC;border-color:#c7d4e4}
/* ── Tab Navigation ─────────────────────────────────────── */ /* ── Tab Navigation ─────────────────────────────────────── */
.tab-nav { .tab-nav { display:flex; gap:0; border-bottom:2px solid var(--gray-200); margin-bottom:24px; flex-wrap:wrap; }
display: flex;
gap: 0;
border-bottom: 2px solid var(--gray-200);
margin-bottom: 24px;
}
.tab-nav button { .tab-nav button {
padding: 12px 24px; padding:12px 22px; background:none; border:none; border-bottom:2px solid transparent; margin-bottom:-2px;
background: none; color:var(--gray-500); font:inherit; font-size:.9rem; font-weight:600; cursor:pointer; transition:all var(--transition-fast);
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
color: var(--gray-500);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
} }
.tab-nav button:hover { .tab-nav button:hover { color:var(--gray-700); background:var(--gray-50); }
color: var(--gray-700); .tab-nav button.active { color:var(--primary); border-bottom-color:var(--primary); }
background: var(--gray-50); .tab-nav button:focus-visible { outline:2px solid var(--primary); outline-offset:-2px; border-radius:4px 4px 0 0; }
} .tab-panel { display:none; }
.tab-nav button.active { .tab-panel.active { display:block; }
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ── Data Table ─────────────────────────────────────────── */ /* ── Data Table ─────────────────────────────────────────── */
.data-table { .data-table { width:100%; border-collapse:collapse; background:var(--card-bg); border-radius:var(--border-radius); overflow:hidden; box-shadow:var(--card-shadow); }
width: 100%; .data-table thead { background:var(--gray-50); }
border-collapse: collapse; .data-table th { text-align:left; padding:11px 16px; font-size:.72rem; font-weight:700; color:var(--gray-500); text-transform:uppercase; letter-spacing:.05em; border-bottom:1px solid var(--gray-200); }
background: var(--card-bg); .data-table td { padding:11px 16px; font-size:.875rem; color:var(--gray-700); border-bottom:1px solid var(--gray-100); }
border-radius: var(--border-radius); .data-table tr:last-child td { border-bottom:none; }
overflow: hidden; .data-table tbody tr:hover td { background:var(--gray-50); }
box-shadow: var(--card-shadow); .data-table tbody tr.clickable-row { cursor:pointer; }
}
.data-table thead {
background: var(--gray-50);
}
.data-table th {
text-align: left;
padding: 12px 16px;
font-size: 0.8rem;
font-weight: 600;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--gray-200);
}
.data-table td {
padding: 12px 16px;
font-size: 0.875rem;
color: var(--gray-700);
border-bottom: 1px solid var(--gray-100);
}
.data-table tr:last-child td { border-bottom: none; }
.data-table tbody tr:hover td { background: var(--gray-50); }
/* ── Status Badges ──────────────────────────────────────── */ /* ── Status Badges ──────────────────────────────────────── */
.status-badge { .status-badge { display:inline-flex; align-items:center; padding:3px 10px; border-radius:20px; font-size:.78rem; font-weight:600; }
display: inline-flex; .status-badge.not_started { background:var(--gray-100); color:var(--gray-600); }
align-items: center; .status-badge.in_progress { background:var(--info-bg); color:var(--info); }
padding: 3px 10px; .status-badge.implemented { background:var(--secondary-bg); color:var(--secondary); }
border-radius: 20px; .status-badge.verified { background:var(--primary-bg); color:var(--primary); }
font-size: 0.78rem;
font-weight: 500;
}
.status-badge.not_started { background: var(--gray-100); color: var(--gray-600); }
.status-badge.in_progress { background: var(--info-bg); color: var(--info); }
.status-badge.implemented { background: var(--secondary-bg); color: var(--secondary); }
.status-badge.verified { background: var(--primary-bg); color: var(--primary); }
/* ── Progress Bar ───────────────────────────────────────── */ /* ── Progress Bar ───────────────────────────────────────── */
.progress-bar-container { .progress-bar-container { display:flex; align-items:center; gap:8px; }
display: flex; .progress-bar { flex-grow:1; height:6px; background:var(--gray-200); border-radius:3px; overflow:hidden; min-width:80px; }
align-items: center; .progress-bar-fill { height:100%; border-radius:3px; transition:width .3s ease; }
gap: 8px; .progress-pct { font-size:.8rem; font-weight:700; min-width:36px; text-align:right; color:var(--gray-600); }
}
.progress-bar { /* ── stat-box (monitoraggio) ────────────────────────────── */
flex-grow: 1; .stat-box { padding:10px 14px; background:var(--gray-50); border-radius:8px; }
height: 6px; .stat-box .stat-value { font-size:1.4rem; font-weight:800; }
background: var(--gray-200); .stat-box .stat-label { font-size:.72rem; color:var(--gray-500); }
border-radius: 3px;
overflow: hidden;
min-width: 80px;
}
.progress-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.progress-pct {
font-size: 0.8rem;
font-weight: 600;
min-width: 36px;
text-align: right;
color: var(--gray-600);
}
/* ── Report Section ─────────────────────────────────────── */ /* ── Report Section ─────────────────────────────────────── */
.report-container { .report-container { background:var(--card-bg); border:1px solid var(--gray-200); border-radius:var(--border-radius); overflow:hidden; }
background: var(--card-bg); .report-header { background:var(--navy-nav,#142B4D); color:#fff; padding:30px; text-align:center; }
border-radius: var(--border-radius); .report-header h3 { font-size:1.3rem; margin:0 0 4px; }
box-shadow: var(--card-shadow); .report-header p { color:#cbd5e1; font-size:.85rem; margin:0; }
overflow: hidden; .report-body { padding:24px 30px; }
} .report-section { margin-bottom:24px; }
.report-header { .report-section-title { font-size:1rem; font-weight:700; color:var(--gray-800); margin-bottom:12px; padding-bottom:8px; border-bottom:2px solid var(--primary-bg); }
background: var(--gray-800); .report-summary-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:16px; }
color: white; .report-summary-item { text-align:center; padding:16px; background:var(--gray-50); border-radius:var(--border-radius); }
padding: 32px; .report-summary-value { font-size:1.8rem; font-weight:800; color:var(--gray-800); line-height:1.2; }
text-align: center; .report-summary-label { font-size:.8rem; color:var(--gray-500); margin-top:4px; }
} .report-compliance-bar { height:20px; background:var(--gray-200); border-radius:10px; overflow:hidden; margin:12px 0; }
.report-header h3 { .report-compliance-fill { height:100%; border-radius:10px; transition:width .5s ease; display:flex; align-items:center; justify-content:center; color:#fff; font-size:.75rem; font-weight:700; }
font-size: 1.3rem;
margin-bottom: 4px;
}
.report-header p {
color: var(--gray-300);
font-size: 0.85rem;
}
.report-body {
padding: 24px 32px;
}
.report-section {
margin-bottom: 24px;
}
.report-section-title {
font-size: 1rem;
font-weight: 600;
color: var(--gray-800);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--primary-bg);
}
.report-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
}
.report-summary-item {
text-align: center;
padding: 16px;
background: var(--gray-50);
border-radius: var(--border-radius);
}
.report-summary-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--gray-800);
line-height: 1.2;
}
.report-summary-label {
font-size: 0.8rem;
color: var(--gray-500);
margin-top: 4px;
}
.report-compliance-bar {
height: 20px;
background: var(--gray-200);
border-radius: 10px;
overflow: hidden;
margin: 12px 0;
}
.report-compliance-fill {
height: 100%;
border-radius: 10px;
transition: width 0.5s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 0.75rem;
font-weight: 600;
}
/* ── ISO Mapping ────────────────────────────────────────── */ /* ── ISO Mapping ────────────────────────────────────────── */
.iso-mapping-row { .iso-mapping-row { display:flex; align-items:center; gap:16px; padding:12px 0; border-bottom:1px solid var(--gray-100); }
display: flex; .iso-mapping-row:last-child { border-bottom:none; }
align-items: center; .iso-nis2-badge { background:var(--primary-bg); color:var(--primary); padding:6px 14px; border-radius:20px; font-size:.82rem; font-weight:700; white-space:nowrap; min-width:90px; text-align:center; }
gap: 16px; .iso-arrow { color:var(--gray-300); flex-shrink:0; }
padding: 12px 0; .iso-arrow svg { width:20px; height:20px; }
border-bottom: 1px solid var(--gray-100); .iso-controls { display:flex; flex-wrap:wrap; gap:6px; flex-grow:1; }
} .iso-control-badge { background:var(--secondary-bg); color:var(--secondary); padding:4px 10px; border-radius:20px; font-size:.78rem; font-weight:600; }
.iso-mapping-row:last-child { border-bottom: none; } .iso-title { font-size:.85rem; color:var(--gray-500); min-width:200px; }
.iso-nis2-badge {
background: var(--primary-bg);
color: var(--primary);
padding: 6px 14px;
border-radius: 20px;
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
min-width: 90px;
text-align: center;
}
.iso-arrow {
color: var(--gray-300);
flex-shrink: 0;
}
.iso-arrow svg { width: 20px; height: 20px; }
.iso-controls {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex-grow: 1;
}
.iso-control-badge {
background: var(--secondary-bg);
color: var(--secondary);
padding: 4px 10px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 500;
}
.iso-title {
font-size: 0.85rem;
color: var(--gray-500);
min-width: 200px;
}
/* ── Pagination ─────────────────────────────────────────── */ /* ── Pagination ─────────────────────────────────────────── */
.pagination { .pagination { display:flex; justify-content:center; align-items:center; gap:12px; margin-top:20px; padding:12px; }
display: flex; .pagination button { padding:8px 16px; border:1px solid var(--gray-200); background:var(--card-bg); border-radius:var(--border-radius-sm); color:var(--gray-600); cursor:pointer; font:inherit; font-size:.85rem; transition:all var(--transition-fast); }
justify-content: center; .pagination button:hover:not(:disabled) { background:var(--primary-bg); border-color:var(--primary); color:var(--primary); }
align-items: center; .pagination button:disabled { opacity:.4; cursor:not-allowed; }
gap: 12px; .pagination .page-info { font-size:.85rem; color:var(--gray-500); }
margin-top: 20px;
padding: 12px;
}
.pagination button {
padding: 8px 16px;
border: 1px solid var(--gray-200);
background: var(--card-bg);
border-radius: var(--border-radius-sm);
color: var(--gray-600);
cursor: pointer;
font-size: 0.85rem;
transition: all var(--transition-fast);
}
.pagination button:hover:not(:disabled) {
background: var(--primary-bg);
border-color: var(--primary);
color: var(--primary);
}
.pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pagination .page-info {
font-size: 0.85rem;
color: var(--gray-500);
}
/* ── Audit Log Action ───────────────────────────────────── */ /* ── Audit Log ──────────────────────────────────────────── */
.log-action { .log-action { display:inline-flex; align-items:center; gap:4px; font-size:.82rem; }
display: inline-flex; .log-entity { background:var(--gray-100); padding:2px 8px; border-radius:var(--border-radius-sm); font-size:.8rem; color:var(--gray-600); font-family:var(--font-mono); }
align-items: center; .log-details { display:inline-block; max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:.8rem; color:var(--gray-400); cursor:help; vertical-align:bottom; }
gap: 4px;
font-size: 0.82rem;
}
.log-entity {
background: var(--gray-100);
padding: 2px 8px;
border-radius: var(--border-radius-sm);
font-size: 0.8rem;
color: var(--gray-600);
font-family: var(--font-mono);
}
.log-details {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.8rem;
color: var(--gray-400);
cursor: help;
}
/* ── Controls Row Clickable ─────────────────────────────── */ /* ── Loading / Empty ────────────────────────────────────── */
.data-table tbody tr.clickable-row { cursor: pointer; } .loading-state { text-align:center; padding:48px 20px; color:var(--gray-400); }
.loading-state .spinner { width:32px; height:32px; border:3px solid var(--gray-200); border-top-color:var(--primary); border-radius:50%; animation:rpt-spin .8s linear infinite; margin:0 auto 12px; }
/* ── Loading/Empty ──────────────────────────────────────── */ @keyframes rpt-spin { to { transform:rotate(360deg); } }
.loading-state { .empty-state-box { text-align:center; padding:48px 20px; color:var(--gray-400); }
text-align: center; .empty-state-box svg { width:48px; height:48px; margin-bottom:12px; opacity:.5; }
padding: 48px 20px; .empty-state-box h4 { color:var(--gray-600); margin:0 0 4px; }
color: var(--gray-400); .empty-state-box p { margin:0; }
}
.loading-state .spinner {
width: 32px;
height: 32px;
border: 3px solid var(--gray-200);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state-box {
text-align: center;
padding: 48px 20px;
color: var(--gray-400);
}
.empty-state-box svg {
width: 48px;
height: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.empty-state-box h4 {
color: var(--gray-600);
margin-bottom: 4px;
}
/* ── Print ──────────────────────────────────────────────── */ /* ── Print ──────────────────────────────────────────────── */
@media print { @media print {
.sidebar, .content-header, .tab-nav, .btn { display: none !important; } #topnav, .crumb, .page-head .btn-primary, .tab-nav, .btn { display:none !important; }
.main-content { margin: 0 !important; padding: 0 !important; } .wrap { max-width:100% !important; padding:0 !important; }
.report-container { box-shadow: none; border: 1px solid #ddd; } .report-container { box-shadow:none; border:1px solid #ddd; }
body { background: white; } body { background:#fff; }
} }
/* ── Responsive ─────────────────────────────────────────── */ /* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) { @media (max-width:768px) {
.tab-nav button { padding: 10px 12px; font-size: 0.8rem; } .tab-nav button { padding:10px 12px; font-size:.8rem; }
.iso-mapping-row { flex-direction: column; align-items: flex-start; } .iso-mapping-row { flex-direction:column; align-items:flex-start; }
.iso-title { min-width: auto; } .iso-title { min-width:auto; }
.report-summary-grid { grid-template-columns: 1fr 1fr; } .report-summary-grid { grid-template-columns:1fr 1fr; }
} }
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
@@ -358,111 +141,110 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content"> <main class="wrap">
<header class="content-header"> <nav class="crumb" aria-label="breadcrumb">
<h1 class="h2" data-i18n="audit.title">Audit &amp; Report</h1> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<div class="content-header-actions"> <i data-lucide="chevron-right" class="ic"></i><span>Audit e Report</span>
<button class="btn btn-primary" onclick="generateReport()">Genera Report</button> </nav>
</div> <div class="page-head">
</header> <div>
<h1 data-i18n="audit.title">Audit &amp; Report</h1>
<p class="sub">Report di compliance, controlli, audit log e mapping ISO 27001</p>
</div>
<button class="btn-primary" onclick="generateReport()"><i data-lucide="file-text" class="ic"></i>Genera Report</button>
</div>
<div class="content-body"> <section class="card card-pad">
<!-- Tab Navigation --> <!-- Tab Navigation -->
<div class="tab-nav"> <div class="tab-nav">
<button class="active" onclick="switchTab('report', this)">Report Compliance</button> <button class="active" onclick="switchTab('report', this)">Report Compliance</button>
<button onclick="switchTab('controls', this)">Controlli</button> <button onclick="switchTab('controls', this)">Controlli</button>
<button onclick="switchTab('audit', this)">Audit Log</button> <button onclick="switchTab('audit', this)">Audit Log</button>
<button onclick="switchTab('iso', this)">ISO 27001</button> <button onclick="switchTab('iso', this)">ISO 27001</button>
<button onclick="switchTab('monitoring', this)" data-i18n="audit.monitoring_tab">Monitoraggio Continuo</button> <button onclick="switchTab('monitoring', this)" data-i18n="audit.monitoring_tab">Monitoraggio Continuo</button>
<button onclick="switchTab('acn', this)">Requisiti ACN</button> <button onclick="switchTab('acn', this)">Requisiti ACN</button>
</div> </div>
<!-- Tab: Report Compliance --> <!-- Tab: Report Compliance -->
<div class="tab-panel active" id="tab-report"> <div class="tab-panel active" id="tab-report">
<div id="report-container"> <div id="report-container">
<div class="empty-state-box"> <div class="empty-state-box">
<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> <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>
<h4>Nessun report generato</h4> <h4>Nessun report generato</h4>
<p>Clicca "Genera Report" per creare un report di compliance aggiornato.</p> <p>Clicca "Genera Report" per creare un report di compliance aggiornato.</p>
</div>
</div>
</div>
<!-- Tab: Controlli -->
<div class="tab-panel" id="tab-controls">
<div id="controls-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento controlli...</p>
</div>
</div>
</div>
<!-- Tab: Audit Log -->
<div class="tab-panel" id="tab-audit">
<div id="audit-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento log...</p>
</div>
</div>
</div>
<!-- Tab: ISO 27001 -->
<div class="tab-panel" id="tab-iso">
<div id="iso-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento mapping...</p>
</div>
</div>
</div>
<!-- Tab: Continuous Control Monitoring -->
<div class="tab-panel" id="tab-monitoring">
<div id="monitoring-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento monitoraggio...</p>
</div>
</div>
</div>
<div class="tab-panel" id="tab-acn">
<div id="acn-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento requisiti ACN...</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</main>
</div>
<script src="/js/api.js?v=20260631"></script> <!-- Tab: Controlli -->
<script src="/js/common.js?v=20260631"></script> <div class="tab-panel" id="tab-controls">
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <div id="controls-container">
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, <div class="loading-state">
riusando tutti gli helper di common.js (zero backend). --> <div class="spinner"></div>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <p>Caricamento controlli...</p>
<script> </div>
if (window.bootstrap && bootstrap.loadFonts) { </div>
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); </div>
}
</script> <!-- Tab: Audit Log -->
<script src="/js/common-bi.js?v=20260631"></script> <div class="tab-panel" id="tab-audit">
<script src="/js/i18n.js?v=20260631"></script> <div id="audit-container">
<script src="/js/help.js?v=20260618p2"></script> <div class="loading-state">
<div class="spinner"></div>
<p>Caricamento log...</p>
</div>
</div>
</div>
<!-- Tab: ISO 27001 -->
<div class="tab-panel" id="tab-iso">
<div id="iso-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento mapping...</p>
</div>
</div>
</div>
<!-- Tab: Continuous Control Monitoring -->
<div class="tab-panel" id="tab-monitoring">
<div id="monitoring-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento monitoraggio...</p>
</div>
</div>
</div>
<div class="tab-panel" id="tab-acn">
<div id="acn-container">
<div class="loading-state">
<div class="spinner"></div>
<p>Caricamento requisiti ACN...</p>
</div>
</div>
</div>
</section>
</main>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → init pagina -->
<script src="/js/api.js?v=20260730a"></script>
<script src="/js/common.js?v=20260729b"></script>
<script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
I18n.init(); if (typeof I18n !== 'undefined') I18n.init();
HelpSystem.init(); if (typeof HelpSystem !== 'undefined') HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
// ── Labels ────────────────────────────────────────────── // ── Labels ──────────────────────────────────────────────
const controlStatusLabels = { const controlStatusLabels = {
@@ -1017,14 +799,11 @@
} }
let html = ` let html = `
<div class="card"> <div>
<div class="card-header"> <h3 style="margin:0 0 4px;font-size:1.05rem;">Mapping NIS2 - ISO 27001:2022</h3>
<h3>Mapping NIS2 - ISO 27001:2022</h3> <p style="font-size:0.85rem; color:var(--gray-500); margin-bottom:20px;">
</div> Corrispondenza tra gli articoli della Direttiva NIS2 e i controlli dello standard ISO/IEC 27001:2022.
<div class="card-body"> </p>`;
<p style="font-size:0.85rem; color:var(--gray-500); margin-bottom:20px;">
Corrispondenza tra gli articoli della Direttiva NIS2 e i controlli dello standard ISO/IEC 27001:2022.
</p>`;
mapping.forEach(m => { mapping.forEach(m => {
const isoControls = (m.iso27001 || '').split(',').map(c => c.trim()).filter(c => c); const isoControls = (m.iso27001 || '').split(',').map(c => c.trim()).filter(c => c);
@@ -1041,7 +820,7 @@
</div>`; </div>`;
}); });
html += '</div></div>'; html += '</div>';
container.innerHTML = html; container.innerHTML = html;
} }
+104 -88
View File
@@ -3,51 +3,60 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Scadenziario - NIS2 Agile</title> <title>Scadenziario · NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. --> <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/style.css?v=20260618"> <link rel="stylesheet" href="/css/v3.css?v=20260627">
<style> <style>
.rev-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:16px; font-size:.92rem; line-height:1.6; } /* V3 — specifico pagina Scadenziario (non coperto da v3.css) */
.rev-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 18px; } .rev-intro { background:#EAF1FA; border-left:4px solid var(--c-blue); padding:14px 18px; border-radius:10px; margin-bottom:16px; font-size:13.5px; line-height:1.6; color:var(--ink); }
.rev-stats { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:18px; } .rev-note { font-size:12.5px; color:#92400e; background:#FBF1E2; border:1px solid #fde68a; border-radius:10px; padding:10px 12px; margin:0 0 18px; }
.rev-stat { flex:1 1 150px; min-width:130px; background:#fff; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; padding:12px 14px; } .rev-stats { display:flex; flex-wrap:wrap; gap:12px; margin-bottom:18px; }
.rev-stat .n { font-size:1.5rem; font-weight:800; line-height:1; } .rev-stat { flex:1 1 150px; min-width:130px; background:#fff; border:1px solid var(--line); border-radius:11px; padding:14px 16px; box-shadow:var(--shadow); }
.rev-stat .l { font-size:.78rem; color:var(--gray-500,#6b7280); margin-top:4px; } .rev-stat .n { font-size:27px; font-weight:800; line-height:1; }
.rev-stat .l { font-size:11px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:#7a8aa0; margin-top:6px; }
.rev-stat.s-overdue .n { color:#991b1b; } .rev-stat.s-overdue .n { color:#991b1b; }
.rev-stat.s-due .n { color:#b45309; } .rev-stat.s-due .n { color:#b45309; }
.rev-stat.s-ok .n { color:#166534; } .rev-stat.s-ok .n { color:#166534; }
.rev-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; } .rev-toolbar { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.rev-toolbar select { padding:8px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.88rem; } .rev-toolbar select { padding:8px 11px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:13.5px; background:#fff; color:var(--ink); }
.rev-toolbar .grow { flex:1 1 auto; } .rev-toolbar .grow { flex:1 1 auto; }
.rev-table { width:100%; border-collapse:collapse; font-size:.88rem; } .rev-table { width:100%; border-collapse:collapse; font-size:13px; }
.rev-table th, .rev-table td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--gray-100,#f3f4f6); vertical-align:top; } .rev-table th, .rev-table td { text-align:left; padding:10px 12px; border-bottom:1px solid var(--line); vertical-align:top; }
.rev-table th { font-size:.74rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); } .rev-table th { font-size:10.5px; text-transform:uppercase; letter-spacing:.04em; color:#7a8aa0; }
.rev-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; } .rev-badge { display:inline-block; font-size:10.5px; font-weight:700; padding:2px 8px; border-radius:6px; }
.rev-badge.type { background:#f1f5f9; color:#475569; } .rev-badge.type { background:#EEF2F7; color:#475569; }
.gap-pill { display:inline-block; font-size:.72rem; font-weight:800; padding:2px 9px; border-radius:20px; } .gap-pill { display:inline-block; font-size:11px; font-weight:800; padding:2px 9px; border-radius:20px; }
.gap-pill.ok { background:#dcfce7; color:#166534; } .gap-pill.ok { background:#E6F4EC; color:#166534; }
.gap-pill.due { background:#fef3c7; color:#92400e; } .gap-pill.due { background:#FBF1E2; color:#92400e; }
.gap-pill.bad { background:#fee2e2; color:#991b1b; } .gap-pill.bad { background:#FBE9E9; color:#991b1b; }
.rev-actions-cell { display:flex; gap:6px; flex-wrap:wrap; } .rev-actions-cell { display:flex; gap:6px; flex-wrap:wrap; }
.rev-actions-cell .btn, .rev-toolbar .btn { padding:4px 9px; font-size:.76rem; } .rev-empty { text-align:center; padding:34px 16px; color:var(--muted); }
.rev-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); } /* Modale (page-owned) */
/* Modale */ .rm-overlay { display:none; position:fixed; inset:0; background:rgba(15,27,46,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 16px; }
.rm-overlay { display:none; position:fixed; inset:0; background:rgba(15,23,42,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 16px; }
.rm-overlay.open { display:flex; } .rm-overlay.open { display:flex; }
.rm-dialog { background:#fff; border-radius:12px; width:100%; max-width:560px; box-shadow:0 20px 50px rgba(0,0,0,.25); } .rm-dialog { background:#fff; border-radius:12px; width:100%; max-width:560px; box-shadow:0 20px 50px rgba(11,40,80,.25); }
.rm-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); } .rm-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--line); }
.rm-head h3 { margin:0; font-size:1.05rem; } .rm-head h3 { margin:0; font-size:16px; }
.rm-body { padding:18px 20px; } .rm-body { padding:18px 20px; }
.rm-foot { padding:14px 20px; border-top:1px solid var(--gray-100,#f3f4f6); display:flex; justify-content:flex-end; gap:10px; } .rm-foot { padding:14px 20px; border-top:1px solid var(--line); display:flex; justify-content:flex-end; gap:10px; }
.rm-field { margin-bottom:14px; } .rm-field { margin-bottom:14px; }
.rm-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; } .rm-field label { display:block; font-weight:600; font-size:13px; margin-bottom:4px; }
.rm-field input[type=text], .rm-field input[type=number], .rm-field input[type=date], .rm-field select, .rm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; } .rm-field input[type=text], .rm-field input[type=number], .rm-field input[type=date], .rm-field select, .rm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--line); border-radius:8px; font:inherit; font-size:14px; }
.rm-field textarea { min-height:64px; resize:vertical; } .rm-field textarea { min-height:64px; resize:vertical; }
.rm-row { display:flex; gap:12px; } .rm-row { display:flex; gap:12px; }
.rm-row > .rm-field { flex:1 1 0; } .rm-row > .rm-field { flex:1 1 0; }
.rm-err { color:#b91c1c; font-size:.82rem; min-height:18px; } .rm-err { color:#b91c1c; font-size:12.5px; min-height:18px; }
.rm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); } .rm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--muted); }
/* Toast prodotti da common.js (style.css rimosso): stile minimo coerente V3 */
.notification-container{position:fixed;top:18px;right:18px;z-index:1100;display:flex;flex-direction:column;gap:10px}
.notification{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-left:4px solid var(--c-blue);border-radius:10px;box-shadow:0 8px 24px rgba(16,40,80,.14);padding:12px 14px;min-width:240px;max-width:360px;font-size:13px}
.notification.success{border-left-color:var(--c-green)}.notification.error{border-left-color:var(--c-red)}.notification.warning{border-left-color:var(--c-amber)}
.notification-icon{width:18px;height:18px;flex:0 0 auto}.notification-icon svg{width:18px;height:18px}
.notification.success .notification-icon{color:var(--c-green)}.notification.error .notification-icon{color:var(--c-red)}.notification.warning .notification-icon{color:var(--c-amber)}.notification.info .notification-icon{color:var(--c-blue)}
.notification-message{flex:1;min-width:0}
.notification-close{background:none;border:0;font-size:1.2rem;line-height:1;cursor:pointer;color:var(--muted)}
.notification.fade-out{opacity:0;transition:opacity .3s}
</style> </style>
<!-- PWA:start --> <!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
@@ -64,49 +73,55 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="rev.title">Scadenziario</h2>
<div class="content-header-actions">
<span class="savehint" id="rev-hint" role="status" aria-live="polite"></span>
</div>
</header>
<div class="content-body"> <main class="wrap">
<div class="rev-intro"> <nav class="crumb" aria-label="breadcrumb">
<strong>Revisioni e scadenze in un unico posto.</strong> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
Tieni traccia delle revisioni periodiche di ruoli, competenze, inventario, procedure, rischi, fornitori e misure. Ogni voce mostra quando è prevista la prossima revisione e ti avvisa quando è in scadenza o scaduta. <i data-lucide="chevron-right" class="ic"></i><span>Monitoraggio</span>
</div> <i data-lucide="chevron-right" class="ic"></i><span>Scadenziario</span>
<div class="rev-note"> </nav>
Ancoraggio: <strong>GV.PO-02</strong> (riesame periodico delle policy), <strong>GV.SC-07</strong> (revisione dei fornitori), <strong>PR.AT</strong>, <strong>DE.CM</strong> (monitoraggio continuo). Gli obblighi di gestione del rischio fanno capo all'<strong>art. 24 D.Lgs. 138/2024</strong>. La periodicità puntuale è una buona prassi dove non fissata da norma o contratto. Strumento di supporto, non un parere legale. <div class="page-head">
</div> <div>
<h1 data-i18n="rev.title">Scadenziario</h1>
<div class="rev-stats" id="rev-stats" style="display:none;"> <p class="sub">Revisioni periodiche di ruoli, competenze, inventario, procedure, rischi, fornitori e misure</p>
<div class="rev-stat"><div class="n" id="rev-stat-total">0</div><div class="l">Voci totali</div></div>
<div class="rev-stat s-overdue"><div class="n" id="rev-stat-overdue">0</div><div class="l">Scadute</div></div>
<div class="rev-stat s-due"><div class="n" id="rev-stat-due">0</div><div class="l">In scadenza (30 gg)</div></div>
<div class="rev-stat s-ok"><div class="n" id="rev-stat-ok">0</div><div class="l">In regola</div></div>
</div>
<div class="rev-toolbar">
<button class="btn btn-primary btn-sm" onclick="revOpen()">+ Nuova scadenza</button>
<button class="btn btn-outline btn-sm" id="rev-sync-btn" onclick="revDoSync()">Sincronizza scadenze esistenti</button>
<span class="grow"></span>
<label for="rev-filter" style="font-weight:600;font-size:.82rem;">Stato:</label>
<select id="rev-filter" onchange="revRender()">
<option value="all">Tutti</option>
<option value="overdue">Scadute</option>
<option value="due">In scadenza</option>
<option value="ok">In regola</option>
</select>
</div>
<div id="rev-list" aria-live="polite"></div>
</div> </div>
</main> <span class="savehint" id="rev-hint" role="status" aria-live="polite" style="font-size:12.5px;color:var(--muted)"></span>
</div> </div>
<section class="card card-pad">
<div class="rev-intro">
<strong>Revisioni e scadenze in un unico posto.</strong>
Tieni traccia delle revisioni periodiche di ruoli, competenze, inventario, procedure, rischi, fornitori e misure. Ogni voce mostra quando è prevista la prossima revisione e ti avvisa quando è in scadenza o scaduta.
</div>
<div class="rev-note">
Ancoraggio: <strong>GV.PO-02</strong> (riesame periodico delle policy), <strong>GV.SC-07</strong> (revisione dei fornitori), <strong>PR.AT</strong>, <strong>DE.CM</strong> (monitoraggio continuo). Gli obblighi di gestione del rischio fanno capo all'<strong>art. 24 D.Lgs. 138/2024</strong>. La periodicità puntuale è una buona prassi dove non fissata da norma o contratto. Strumento di supporto, non un parere legale.
</div>
<div class="rev-stats" id="rev-stats" style="display:none;">
<div class="rev-stat"><div class="n" id="rev-stat-total">0</div><div class="l">Voci totali</div></div>
<div class="rev-stat s-overdue"><div class="n" id="rev-stat-overdue">0</div><div class="l">Scadute</div></div>
<div class="rev-stat s-due"><div class="n" id="rev-stat-due">0</div><div class="l">In scadenza (30 gg)</div></div>
<div class="rev-stat s-ok"><div class="n" id="rev-stat-ok">0</div><div class="l">In regola</div></div>
</div>
<div class="rev-toolbar">
<button class="btn-primary" onclick="revOpen()"><i data-lucide="plus" class="ic"></i>Nuova scadenza</button>
<button class="btn-ghost" id="rev-sync-btn" onclick="revDoSync()"><i data-lucide="refresh-cw" class="ic"></i>Sincronizza scadenze esistenti</button>
<span class="grow"></span>
<label for="rev-filter" style="font-weight:600;font-size:13px;">Stato:</label>
<select id="rev-filter" onchange="revRender()">
<option value="all">Tutti</option>
<option value="overdue">Scadute</option>
<option value="due">In scadenza</option>
<option value="ok">In regola</option>
</select>
</div>
<div id="rev-list" aria-live="polite"></div>
</section>
</main>
<!-- Modale scadenza (crea/modifica) --> <!-- Modale scadenza (crea/modifica) -->
<div class="rm-overlay" id="rev-modal" role="dialog" aria-modal="true" aria-labelledby="rev-modal-title"> <div class="rm-overlay" id="rev-modal" role="dialog" aria-modal="true" aria-labelledby="rev-modal-title">
@@ -137,16 +152,17 @@
<div class="rm-field"><label for="rev-f-notes">Note</label><textarea id="rev-f-notes" maxlength="2000"></textarea></div> <div class="rm-field"><label for="rev-f-notes">Note</label><textarea id="rev-f-notes" maxlength="2000"></textarea></div>
<div class="rm-err" id="rev-err" role="alert"></div> <div class="rm-err" id="rev-err" role="alert"></div>
</div> </div>
<div class="rm-foot"><button class="btn btn-outline" type="button" onclick="revClose()">Annulla</button><button class="btn btn-primary" type="button" id="rev-save" onclick="revSave()">Salva</button></div> <div class="rm-foot"><button class="btn-ghost" type="button" onclick="revClose()">Annulla</button><button class="btn-primary" type="button" id="rev-save" onclick="revSave()">Salva</button></div>
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3 (ordine ESATTO come dashboard.html) -->
<script src="/js/common.js?v=20260631"></script> <script src="/js/api.js?v=20260730a"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/common.js?v=20260729b"></script>
<script src="/js/common-bi.js?v=20260631"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/i18n.js?v=20260631"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/help.js?v=20260618p2"></script> <script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
'use strict'; 'use strict';
/* /*
@@ -180,6 +196,7 @@
if (window.I18n && I18n.init) I18n.init('it'); if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar(); if (typeof loadSidebar === 'function') loadSidebar();
if (window.HelpSystem && HelpSystem.init) HelpSystem.init(); if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') revClose(); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') revClose(); });
const ov = el('rev-modal'); const ov = el('rev-modal');
@@ -195,7 +212,6 @@
REV.roles = (rolesData && rolesData.roles) || []; REV.roles = (rolesData && rolesData.roles) || [];
revFillOwnerSelect(); revFillOwnerSelect();
} catch (e) { } catch (e) {
// l'organigramma può non essere ancora popolato: non bloccare la pagina
REV.roles = []; REV.roles = [];
} }
} }
@@ -210,7 +226,7 @@
} }
async function revLoad() { async function revLoad() {
el('rev-list').innerHTML = '<div class="rev-empty">Caricamento…</div>'; el('rev-list').innerHTML = '<div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div>';
try { try {
const data = await api.revList(); const data = await api.revList();
REV.items = (data && data.items) || []; REV.items = (data && data.items) || [];
@@ -250,16 +266,16 @@
const freq = (i.frequency_months != null && i.frequency_months !== '') ? (esc(i.frequency_months) + ' mesi') : ''; const freq = (i.frequency_months != null && i.frequency_months !== '') ? (esc(i.frequency_months) + ' mesi') : '';
return '<tr>' return '<tr>'
+ '<td><strong>' + esc(i.title) + '</strong>' + '<td><strong>' + esc(i.title) + '</strong>'
+ (i.notes ? '<div class="gap-role-sub" style="font-size:.8rem;color:var(--gray-500,#6b7280);margin-top:3px;">' + esc(i.notes) + '</div>' : '') + '</td>' + (i.notes ? '<div style="font-size:12px;color:var(--muted);margin-top:3px;">' + esc(i.notes) + '</div>' : '') + '</td>'
+ '<td><span class="rev-badge type">' + esc(typeLabel(i.entity_type)) + '</span></td>' + '<td><span class="rev-badge type">' + esc(typeLabel(i.entity_type)) + '</span></td>'
+ '<td>' + owner + '</td>' + '<td>' + owner + '</td>'
+ '<td>' + fmtDate(i.last_reviewed_at) + '</td>' + '<td>' + fmtDate(i.last_reviewed_at) + '</td>'
+ '<td>' + fmtDate(i.next_review_date) + (freq ? '<div style="font-size:.78rem;color:var(--gray-500,#6b7280);">ogni ' + freq + '</div>' : '') + '</td>' + '<td>' + fmtDate(i.next_review_date) + (freq ? '<div style="font-size:12px;color:var(--muted);">ogni ' + freq + '</div>' : '') + '</td>'
+ '<td><span class="gap-pill ' + meta.cls + '">' + meta.label + '</span></td>' + '<td><span class="gap-pill ' + meta.cls + '">' + meta.label + '</span></td>'
+ '<td><div class="rev-actions-cell">' + '<td><div class="rev-actions-cell">'
+ '<button class="btn btn-outline" onclick="revComplete(' + i.id + ')">Segna revisionato</button>' + '<button class="btn-ghost" onclick="revComplete(' + i.id + ')">Segna revisionato</button>'
+ '<button class="btn btn-outline" onclick="revOpen(' + i.id + ')">Modifica</button>' + '<button class="btn-ghost" onclick="revOpen(' + i.id + ')">Modifica</button>'
+ '<button class="btn btn-outline" onclick="revDelete(' + i.id + ')">Elimina</button>' + '<button class="btn-ghost" onclick="revDelete(' + i.id + ')">Elimina</button>'
+ '</div></td>' + '</div></td>'
+ '</tr>'; + '</tr>';
}).join(''); }).join('');
+830 -392
View File
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Segnala in anonimato</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- ANONIMATO (StarGateWB/Vocea): niente referrer, niente analytics, niente login, niente tracciamento. -->
<meta name="referrer" content="no-referrer">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; frame-src https://app.vocea.cloud; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'">
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<style>
body { background:#f5f7fa; margin:0; }
.wb-wrap { max-width:860px; margin:0 auto; padding:28px 16px; }
.wb-card { background:#fff; border-radius:14px; box-shadow:0 6px 24px rgba(0,0,0,.08); padding:26px; }
.wb-frame { width:100%; height:760px; border:0; border-radius:10px; background:#fafbfc; }
.wb-note { color:#5b6b7b; font-size:.94rem; line-height:1.55; }
.wb-badge { display:inline-block; background:#e7f3ff; color:#0066CC; border-radius:999px; padding:3px 12px; font-size:.8rem; font-weight:600; }
h1 { font-size:1.6rem; margin:12px 0 6px; }
</style>
</head>
<body>
<!-- PAGINA PUBBLICA, PRE-LOGIN. Nessuno script di app (no common.js/api.js/pwa.js),
nessuna sidebar, nessun checkAuth, nessuna analitica: l'accesso non è tracciato. -->
<div class="wb-wrap">
<div class="wb-card">
<span class="wb-badge">Canale sicuro · cifratura zero-knowledge</span>
<h1>Segnala in anonimato</h1>
<p class="wb-note">
Questo canale è cifrato end-to-end: il contenuto della segnalazione e la tua identità non sono
visibili a nessuno, nemmeno a chi gestisce la piattaforma. <strong>Non è richiesto alcun accesso</strong>
e il tuo passaggio non viene registrato. Al termine ti verrà mostrato un <strong>codice di recupero</strong>:
conservalo per seguire lo stato della tua segnalazione (è l'unico modo, non è recuperabile).
</p>
<div id="wb-mount"></div>
</div>
</div>
<script>
(function () {
'use strict';
var VOCEA_ORIGIN = 'https://app.vocea.cloud';
// Lo slug è l'ID del CANALE pubblico dell'organizzazione (NON un identificativo del segnalante),
// passato come ?canale=<slug>. Validazione stretta.
var qs = new URLSearchParams(location.search);
var slug = (qs.get('canale') || '').trim().toLowerCase();
var theme = qs.get('theme') === 'dark' ? '&theme=dark' : '';
var mount = document.getElementById('wb-mount');
if (!/^[a-z0-9-]{2,64}$/.test(slug)) {
mount.innerHTML = '<p class="wb-note">Canale non ancora configurato. Apri il link fornito dalla tua organizzazione '
+ '(formato: <code>…/segnala-anonimo.html?canale=&lt;nome-canale&gt;</code>).</p>';
return;
}
var f = document.createElement('iframe');
f.className = 'wb-frame';
f.title = 'Canale di segnalazione anonima';
f.setAttribute('referrerpolicy', 'no-referrer'); // nessun referrer verso Vocea
f.setAttribute('allow', 'clipboard-write'); // per copiare il codice di recupero
// Solo slug + embed: MAI identificativi (email/employee_id/token) nell'URL.
f.src = VOCEA_ORIGIN + '/it/segnala/' + encodeURIComponent(slug) + '?embed=1' + theme;
mount.appendChild(f);
// Messaggi dal widget: verifica ORIGIN + envelope {source:'vocea'}.
// NON salviamo né inoltriamo nulla: il codice di recupero resta dentro l'iframe (by design).
window.addEventListener('message', function (ev) {
if (ev.origin !== VOCEA_ORIGIN) return; // 1) origin check
var d = ev.data;
if (!d || d.source !== 'vocea') return; // 2) envelope check
// Gestiamo SOLO eventi non sensibili (autosize). Niente storage, niente analytics, niente forward.
if (d.type === 'resize' && typeof d.height === 'number' && d.height > 200 && d.height < 4000) {
f.style.height = d.height + 'px';
}
}, false);
})();
</script>
</body>
</html>
+529 -489
View File
File diff suppressed because it is too large Load Diff
+41 -19
View File
@@ -3,11 +3,22 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Attività stakeholder - NIS2 Agile</title> <title>Attività stakeholder · NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg CDN / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/css/style.css?v=20260618"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style> <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; } /* ── Mappa variabili legacy (style.css rimosso) → token V3 + widget base ── */
:root {
--primary:#1F5FA8; --gray-50:#F7FAFD; --gray-100:#EEF2F7; --gray-200:#E3E9F0;
--gray-400:#94A3B8; --gray-500:#64748B;
}
.btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; border:1px solid var(--line); background:#fff; color:var(--ink); border-radius:8px; padding:8px 13px; font:inherit; font-weight:600; font-size:12.5px; cursor:pointer; text-decoration:none; }
.btn:hover { background:#F5F8FC; border-color:#c7d4e4; }
.btn-outline { background:#fff; color:var(--ink); border-color:var(--line); }
.btn-outline:hover { background:#F5F8FC; border-color:#c7d4e4; }
.btn-sm { padding:5px 10px; font-size:11.5px; }
.sa-intro { background:#EAF1FA; 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-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-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 { 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; }
@@ -72,12 +83,21 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content">
<header class="content-header"> <main class="wrap">
<h2 data-i18n="sact.title">Attività stakeholder</h2> <nav class="crumb" aria-label="breadcrumb">
</header> <i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<i data-lucide="chevron-right" class="ic"></i><a href="stakeholders.html">Stakeholder</a>
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="sact.title">Attività</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="sact.title">Attività stakeholder</h1>
<p class="sub">Questionari tipo, attività e magic-link verso terze parti (GV.SC-02/05/07 · art. 24 D.Lgs. 138/2024)</p>
</div>
</div>
<div class="content-body"> <div class="content-body">
<div class="sa-intro"> <div class="sa-intro">
<strong>Attività verso gli stakeholder.</strong> <strong>Attività verso gli stakeholder.</strong>
@@ -106,8 +126,7 @@
<div id="tpl-list" aria-live="polite"></div> <div id="tpl-list" aria-live="polite"></div>
</section> </section>
</div> </div>
</main> </main>
</div>
<!-- Modale questionario tipo --> <!-- Modale questionario tipo -->
<div class="sm-overlay" id="tpl-modal" role="dialog" aria-modal="true" aria-labelledby="tpl-modal-title"> <div class="sm-overlay" id="tpl-modal" role="dialog" aria-modal="true" aria-labelledby="tpl-modal-title">
@@ -194,12 +213,14 @@
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="/js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → init pagina -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/api.js?v=20260730a"></script>
<script src="/js/common-bi.js?v=20260631"></script> <script src="/js/common.js?v=20260729b"></script>
<script src="/js/i18n.js?v=20260631"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/help.js?v=20260618p2"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
'use strict'; 'use strict';
/* /*
@@ -217,8 +238,9 @@
document.addEventListener('DOMContentLoaded', async function () { document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return; if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it'); if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar(); if (typeof loadSidebar === 'function') loadSidebar(); // = loadTopnavV3()
if (window.HelpSystem && HelpSystem.init) HelpSystem.init(); if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
document.addEventListener('keydown', e => { if (e.key === 'Escape') { tplClose(); actClose(); detClose(); } }); 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'); }); }); ['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(); await saLoadAll();
+45 -25
View File
@@ -3,12 +3,22 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<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. --> <!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg CDN / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/style.css?v=20260618"> <link rel="stylesheet" href="/css/v3.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; } /* ── Mappa variabili legacy (style.css rimosso) → token V3 + widget base ── */
:root {
--primary:#1F5FA8; --gray-50:#F7FAFD; --gray-100:#EEF2F7; --gray-200:#E3E9F0;
--gray-400:#94A3B8; --gray-500:#64748B;
}
.btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; border:1px solid var(--line); background:#fff; color:var(--ink); border-radius:8px; padding:8px 13px; font:inherit; font-weight:600; font-size:12.5px; cursor:pointer; text-decoration:none; }
.btn:hover { background:#F5F8FC; border-color:#c7d4e4; }
.btn-outline { background:#fff; color:var(--ink); border-color:var(--line); }
.btn-outline:hover { background:#F5F8FC; border-color:#c7d4e4; }
.btn-sm { padding:5px 10px; font-size:11.5px; }
.stk-intro { background:#EAF1FA; 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; }
.stk-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; } .stk-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.stk-toolbar .grow { flex:1 1 auto; } .stk-toolbar .grow { flex:1 1 auto; }
@@ -84,18 +94,26 @@
<!-- PWA:end --> <!-- PWA:end -->
</head> </head>
<body> <body>
<div class="app-layout"> <!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js -->
<aside class="sidebar" id="sidebar"></aside> <div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="stk.title">Stakeholder</h2>
<div class="content-header-actions">
<button class="btn btn-outline btn-sm" onclick="stkTypeOpen()">+ Aggiungi tipo</button>
<button class="btn btn-primary btn-sm" onclick="stkOpen()">+ Aggiungi stakeholder</button>
</div>
</header>
<div class="content-body"> <main class="wrap">
<nav class="crumb" aria-label="breadcrumb">
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="stk.title">Stakeholder</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="stk.title">Stakeholder</h1>
<p class="sub">Registro stakeholder e matrice di Mendelow (GV.SC-02 · art. 24 D.Lgs. 138/2024)</p>
</div>
<div class="content-header-actions" style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn-ghost" onclick="stkTypeOpen()"><i data-lucide="plus" class="ic"></i>Aggiungi tipo</button>
<button class="btn-primary" onclick="stkOpen()"><i data-lucide="plus" class="ic"></i>Aggiungi stakeholder</button>
</div>
</div>
<div class="stack">
<div class="stk-intro"> <div class="stk-intro">
<strong>Mappatura e gestione degli stakeholder.</strong> <strong>Mappatura e gestione degli stakeholder.</strong>
Registra gli stakeholder interni ed esterni scegliendo il tipo dall'elenco (puoi aggiungerne di nuovi), Registra gli stakeholder interni ed esterni scegliendo il tipo dall'elenco (puoi aggiungerne di nuovi),
@@ -129,8 +147,7 @@
<div id="stk-groups" aria-live="polite"></div> <div id="stk-groups" aria-live="polite"></div>
</div> </div>
</main> </main>
</div>
<!-- Modale stakeholder (crea/modifica) --> <!-- Modale stakeholder (crea/modifica) -->
<div class="sm-overlay" id="stk-modal" role="dialog" aria-modal="true" aria-labelledby="stk-modal-title"> <div class="sm-overlay" id="stk-modal" role="dialog" aria-modal="true" aria-labelledby="stk-modal-title">
@@ -191,12 +208,14 @@
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260631"></script> <!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
<script src="/js/common.js?v=20260631"></script> topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → init pagina -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/js/api.js?v=20260730a"></script>
<script src="/js/common-bi.js?v=20260631"></script> <script src="/js/common.js?v=20260729b"></script>
<script src="/js/i18n.js?v=20260631"></script> <script src="/vendor/lucide/lucide.min.js"></script>
<script src="/js/help.js?v=20260618p2"></script> <script src="/js/topnav-v3.js?v=20260729"></script>
<script src="/js/i18n.js?v=20260731"></script>
<script src="/js/help.js?v=20260806b"></script>
<script> <script>
'use strict'; 'use strict';
/* /*
@@ -223,8 +242,9 @@
document.addEventListener('DOMContentLoaded', async function () { document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return; if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it'); if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar(); if (typeof loadSidebar === 'function') loadSidebar(); // = loadTopnavV3()
if (window.HelpSystem && HelpSystem.init) HelpSystem.init(); if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { stkClose(); stkTypeClose(); } }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { stkClose(); stkTypeClose(); } });
['stk-modal','stk-type-modal'].forEach(function (mid) { ['stk-modal','stk-type-modal'].forEach(function (mid) {

Some files were not shown because too many files have changed in this diff Show More