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)
application/config/.db_ssl_on
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_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)
// ═══════════════════════════════════════════════════════════════════════════
+114 -1
View File
@@ -39,12 +39,51 @@ class AiController extends BaseController
}
$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 = [
'user_id' => $userId,
'organization_id' => $this->getCurrentOrgId(), // può essere null
'organization_id' => $orgId ?: 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 {
$aiService = new AIService();
$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');
}
}
/**
* 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_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 {
$existing = $extRef !== null
@@ -340,6 +344,59 @@ class AssetController extends BaseController
$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
{
$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)
? ($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)
Database::query(
'INSERT INTO org_requisito_state
(organization_id, requisito_id, stato, valutazione_rischio, note, updated_by)
VALUES (?, ?, ?, ?, ?, ?)
(organization_id, requisito_id, stato, valutazione_rischio, note, likelihood, impact, residual_likelihood, residual_impact, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
stato = VALUES(stato),
valutazione_rischio = VALUES(valutazione_rischio),
note = VALUES(note),
likelihood = VALUES(likelihood),
impact = VALUES(impact),
residual_likelihood = VALUES(residual_likelihood),
residual_impact = VALUES(residual_impact),
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(
'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 = ?',
[$orgId, $reqId]
);
@@ -222,6 +238,10 @@ class FrameworkController extends BaseController
'stato' => $row['stato'],
'valutazione_rischio' => $row['valutazione_rischio'],
'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'],
], '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;
}
}
$attach = function (array $node) use (&$attach, $childrenByParent) {
$kids = $childrenByParent[(int) $node['id']] ?? [];
$node['children'] = array_map($attach, $kids);
// Guardia anti-ciclo: se parent_role_id formasse un ciclo (dato legacy/corrotto),
// la ricorsione andrebbe in loop infinito → hang del backend e "blocco" della pagina.
// 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 array_map($attach, $roots);
return array_map(static fn(array $r) => $attach($r, []), $roots);
}
}
@@ -124,8 +124,20 @@ class OrganizationController extends BaseController
{
$this->requireAuth();
$firmId = (int) ($this->currentUser['consulting_firm_id'] ?? 0);
if ($this->currentUser['role'] === 'super_admin') {
$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 {
$orgs = Database::fetchAll(
'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']);
}
/**
* 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
*/
@@ -538,4 +623,57 @@ class RiskController extends BaseController
{
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';
$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);
}
/** 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
* Auth: X-API-Key con scope read:compliance
@@ -19,6 +19,7 @@
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/WebhookService.php';
require_once APP_PATH . '/services/VoceaService.php';
class WhistleblowingController extends BaseController
{
@@ -126,6 +127,17 @@ class WhistleblowingController extends BaseController
{
$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 = ?'];
$params = [$this->getCurrentOrgId()];
@@ -383,4 +395,70 @@ class WhistleblowingController extends BaseController
'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";
}
/**
* 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
*/
@@ -702,9 +806,59 @@ PROMPT;
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"
. $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)) {
$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.";
+34 -34
View File
@@ -33,12 +33,12 @@ class AssetScoringService
'max' => 25,
'help' => 'Quanto il sistema e essenziale per l\'erogazione dei servizi core business.',
'options' => [
'critical' => ['label' => 'Critico', 'points' => 25],
'very_high' => ['label' => 'Molto Alto', 'points' => 20],
'high' => ['label' => 'Alto', 'points' => 15],
'medium' => ['label' => 'Medio', 'points' => 10],
'low' => ['label' => 'Basso', 'points' => 5],
'negligible' => ['label' => 'Trascurabile', 'points' => 0],
'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, 'desc' => 'Sistema primario: un fermo degrada gravemente i servizi core, con alternative molto limitate.'],
'high' => ['label' => 'Alto', 'points' => 15, 'desc' => 'Sistema importante: il fermo rallenta i servizi core ma esistono alternative parziali.'],
'medium' => ['label' => 'Medio', 'points' => 10, 'desc' => 'Sistema di supporto: il fermo ha impatto contenuto e gestibile sui servizi core.'],
'low' => ['label' => 'Basso', 'points' => 5, 'desc' => 'Sistema accessorio: il fermo incide poco sull\'operativita.'],
'negligible' => ['label' => 'Trascurabile', 'points' => 0, 'desc' => 'Sistema non essenziale: nessun impatto pratico sui servizi core.'],
],
],
'c2_disruption_impact' => [
@@ -46,12 +46,12 @@ class AssetScoringService
'max' => 25,
'help' => 'Conseguenze di un\'interruzione in termini di durata e utenti impattati.',
'options' => [
'gt24h_gt70' => ['label' => '>24h + >70% utenti', 'points' => 25],
'h8_24_50_70' => ['label' => '8-24h + 50-70% utenti', 'points' => 20],
'h4_8_30_50' => ['label' => '4-8h + 30-50% utenti', 'points' => 15],
'h1_4_10_30' => ['label' => '1-4h + 10-30% utenti', 'points' => 10],
'lt1h_lt10' => ['label' => '<1h + <10% utenti', 'points' => 5],
'none' => ['label' => 'Nessun impatto', 'points' => 0],
'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, '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, '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, 'desc' => 'Interruzione da 1 a 4 ore, dal 10 al 30% degli utenti coinvolti.'],
'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, 'desc' => 'Nessun impatto operativo apprezzabile in caso di interruzione.'],
],
],
'c3_data_processed' => [
@@ -59,11 +59,11 @@ class AssetScoringService
'max' => 20,
'help' => 'Sensibilita e criticita dei dati gestiti dal sistema.',
'options' => [
'gdpr_art9' => ['label' => 'Dati Sensibili Art.9 GDPR', 'points' => 20],
'personal_large' => ['label' => 'Dati Personali larga scala', 'points' => 15],
'personal_fin' => ['label' => 'Dati Personali + Finanziari', 'points' => 10],
'confidential' => ['label' => 'Dati Aziendali Riservati', 'points' => 5],
'public' => ['label' => 'Dati Pubblici', 'points' => 0],
'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, 'desc' => 'Dati personali comuni trattati su larga scala.'],
'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, 'desc' => 'Dati aziendali riservati non personali (know-how, contratti).'],
'public' => ['label' => 'Dati Pubblici', 'points' => 0, 'desc' => 'Solo dati pubblici o gia liberamente divulgabili.'],
],
],
'c4_dependencies' => [
@@ -71,12 +71,12 @@ class AssetScoringService
'max' => 15,
'help' => 'Quanti altri sistemi critici dipendono da questo sistema.',
'options' => [
'ge5_critical' => ['label' => '>=5 sistemi critici', 'points' => 15],
'n3_4_critical' => ['label' => '3-4 sistemi critici', 'points' => 12],
'n2_critical' => ['label' => '2 sistemi critici', 'points' => 9],
'n1_critical' => ['label' => '1 sistema critico', 'points' => 6],
'noncritical' => ['label' => '1-2 sistemi non critici', 'points' => 3],
'none' => ['label' => 'Nessuna dipendenza', 'points' => 0],
'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, 'desc' => 'Da questo sistema dipendono 3 o 4 sistemi critici.'],
'n2_critical' => ['label' => '2 sistemi critici', 'points' => 9, 'desc' => 'Da questo sistema dipendono 2 sistemi critici.'],
'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, 'desc' => 'Dipendono solo 1-2 sistemi non critici.'],
'none' => ['label' => 'Nessuna dipendenza', 'points' => 0, 'desc' => 'Nessun altro sistema dipende da questo.'],
],
],
'c5_exposure' => [
@@ -84,12 +84,12 @@ class AssetScoringService
'max' => 10,
'help' => 'Superficie di attacco ed esposizione del sistema.',
'options' => [
'internet_no_mfa' => ['label' => 'Internet pubblico senza MFA', 'points' => 10],
'internet_mfa' => ['label' => 'Internet con MFA', 'points' => 8],
'partner_net' => ['label' => 'Reti partner/fornitori', 'points' => 6],
'intranet' => ['label' => 'Rete aziendale intranet', 'points' => 4],
'mgmt_isolated' => ['label' => 'Rete gestione isolata', 'points' => 2],
'air_gapped' => ['label' => 'Completamente isolato', 'points' => 0],
'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, 'desc' => 'Esposto su Internet ma protetto da autenticazione a piu fattori.'],
'partner_net' => ['label' => 'Reti partner/fornitori', 'points' => 6, 'desc' => 'Raggiungibile dalle reti di partner o fornitori.'],
'intranet' => ['label' => 'Rete aziendale intranet', 'points' => 4, 'desc' => 'Raggiungibile solo dalla rete aziendale interna.'],
'mgmt_isolated' => ['label' => 'Rete gestione isolata', 'points' => 2, 'desc' => 'Accessibile solo da una rete di gestione isolata.'],
'air_gapped' => ['label' => 'Completamente isolato', 'points' => 0, 'desc' => 'Completamente isolato, senza connettivita di rete.'],
],
],
'c6_regulatory' => [
@@ -97,11 +97,11 @@ class AssetScoringService
'max' => 5,
'help' => 'Se il sistema e soggetto a obblighi normativi o contrattuali specifici.',
'options' => [
'nis2_required' => ['label' => 'Richiesto da NIS2', 'points' => 5],
'mandatory_cert' => ['label' => 'Certificazioni obbligatorie', 'points' => 4],
'strict_sla' => ['label' => 'Obblighi SLA stringenti', 'points' => 3],
'external_audit' => ['label' => 'Audit esterni regolari', 'points' => 2],
'none' => ['label' => 'Nessun obbligo', 'points' => 0],
'nis2_required' => ['label' => 'Richiesto da NIS2', 'points' => 5, 'desc' => 'Sistema che rientra negli obblighi NIS2 dell\'organizzazione.'],
'mandatory_cert' => ['label' => 'Certificazioni obbligatorie', 'points' => 4, 'desc' => 'Soggetto a certificazioni obbligatorie (es. ISO o settoriali).'],
'strict_sla' => ['label' => 'Obblighi SLA stringenti', 'points' => 3, 'desc' => 'Vincolato a SLA contrattuali stringenti.'],
'external_audit' => ['label' => 'Audit esterni regolari', 'points' => 2, 'desc' => 'Sottoposto regolarmente ad audit esterni.'],
'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.
## 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
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.**
### 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
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
# ══════════════════════════════════════════════════════════════════════════
# 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
RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
+3 -3
View File
@@ -70,9 +70,9 @@
</script>
<!-- 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/common.js?v=20260631"></script>
<script src="js/common-bi.js?v=20260631"></script>
<script src="js/api.js?v=20260730a"></script>
<script src="js/common.js?v=20260729b"></script>
<script src="js/common-bi.js?v=20260625d"></script>
<script>
// Renderizza la sidebar BI (loadSidebar è stato ridefinito da common-bi.js)
document.addEventListener('DOMContentLoaded', function () {
+102 -262
View File
@@ -3,184 +3,34 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Pannello Amministrazione - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css resta autorevole per i widget renderizzati via JS -> parita' funzionale.
Pagina in /admin/: gli asset usano path ASSOLUTI (/vendor, /js, /css) serviti dal web root. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Pannello Amministrazione · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css. Pagina in /admin/: asset con path ASSOLUTI. -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style>
.admin-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.admin-stat-card {
background: var(--card-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--card-shadow);
padding: 20px;
transition: box-shadow var(--transition), transform var(--transition);
}
.admin-stat-card:hover {
box-shadow: var(--card-shadow-hover);
transform: translateY(-2px);
}
.admin-stat-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--gray-500);
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;
}
}
/* ── Grafico distribuzione piani (non coperto da v3.css) ────────── */
.chart-container { padding:8px 4px 4px; }
.bar-chart { display:flex; align-items:flex-end; gap:32px; height:200px; padding:0 20px; border-bottom:2px solid var(--line); }
.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 .8s ease; min-height:4px; position:relative; }
.bar-chart-bar.free { background:#94A3B8; }
.bar-chart-bar.professional { background:var(--c-blue); }
.bar-chart-bar.enterprise { background:#7C4DD6; }
.bar-chart-value { font-size:.875rem; font-weight:700; color:var(--ink); margin-bottom:8px; }
.bar-chart-label { font-size:.75rem; color:var(--muted); margin-top:12px; font-weight:600; text-transform:uppercase; letter-spacing:.03em; }
@media (prefers-reduced-motion:reduce) { .bar-chart-bar{transition:none;} }
/* Quick links */
.quick-links-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:16px; }
.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; }
.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; }
.quick-link-icon .ic { width:22px; height:22px; }
.quick-link-info h4 { font-size:.9375rem; font-weight:700; color:var(--ink); margin:0 0 2px; }
.quick-link-info p { font-size:.8125rem; color:var(--muted); margin:0; }
@media (max-width:1024px) { .grid3 { grid-template-columns:repeat(2,1fr); } }
@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; } }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -197,144 +47,134 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h1 class="h2">Pannello Amministrazione</h1>
<div class="content-header-actions">
<span class="badge badge-danger">Super Admin</span>
<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>Amministrazione</span>
</nav>
<div class="page-head">
<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>
</header>
<div class="content-body">
<div class="stack">
<!-- Stats Row 1 -->
<div class="admin-stats-grid" id="stats-row-1">
<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-pa"></use></svg>
<div class="grid3" id="stats-row-1" style="grid-template-columns:repeat(4,1fr)">
<div class="card card-pad stat blue">
<div class="ibox"><i data-lucide="building-2" class="ic"></i></div>
<div style="flex:1;min-width:0">
<div class="lbl" id="lbl-total-orgs">Totale Organizzazioni</div>
<div class="val" id="stat-total-orgs" aria-labelledby="lbl-total-orgs">--</div>
</div>
<div class="admin-stat-label" id="lbl-total-orgs">Totale Organizzazioni</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 class="card card-pad stat green">
<div class="ibox"><i data-lucide="check-circle" class="ic"></i></div>
<div style="flex:1;min-width:0">
<div class="lbl" id="lbl-active-orgs">Organizzazioni Attive</div>
<div class="val" id="stat-active-orgs" aria-labelledby="lbl-active-orgs">--</div>
</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 class="card card-pad stat blue">
<div class="ibox"><i data-lucide="users" class="ic"></i></div>
<div style="flex:1;min-width:0">
<div class="lbl" id="lbl-total-users">Totale Utenti</div>
<div class="val" id="stat-total-users" aria-labelledby="lbl-total-users">--</div>
</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 class="card card-pad stat green">
<div class="ibox"><i data-lucide="user-check" class="ic"></i></div>
<div style="flex:1;min-width:0">
<div class="lbl" id="lbl-active-users">Utenti Attivi</div>
<div class="val" id="stat-active-users" aria-labelledby="lbl-active-users">--</div>
</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>
<!-- Stats Row 2 -->
<div class="admin-stats-grid" id="stats-row-2">
<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-check"></use></svg>
<div class="grid3" id="stats-row-2" style="grid-template-columns:repeat(4,1fr)">
<div class="card card-pad stat blue">
<div class="ibox"><i data-lucide="clipboard-check" class="ic"></i></div>
<div style="flex:1;min-width:0">
<div class="lbl" id="lbl-assessments">Assessment Completati</div>
<div class="val" id="stat-assessments" aria-labelledby="lbl-assessments">--</div>
</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 class="card card-pad stat amber">
<div class="ibox"><i data-lucide="alert-triangle" class="ic"></i></div>
<div style="flex:1;min-width:0">
<div class="lbl" id="lbl-incidents">Incidenti Aperti</div>
<div class="val" id="stat-incidents" aria-labelledby="lbl-incidents">--</div>
</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 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 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 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 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>
<!-- Plans Distribution -->
<div class="card mb-24">
<div class="card-header">
<h3>Distribuzione Piani</h3>
</div>
<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="spinner" style="margin:40px auto;"></div>
</div>
<div class="v3-loading" style="flex:1"><span class="v3-spin"></span>Caricamento…</div>
</div>
</div>
</section>
<!-- Quick Links -->
<div class="card">
<div class="card-header">
<h3>Gestione Piattaforma</h3>
</div>
<div class="card-body">
<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">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg>
</div>
<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">
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
</div>
<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>
</div>
</div>
</section>
</div>
</main>
</div>
<script src="/js/api.js?v=20260620"></script>
<script src="/js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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=20260618"></script>
<script src="/js/i18n.js?v=20260620"></script>
<!-- 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>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
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 ──────────────────────────────────────────
(async function checkAdmin() {
+72 -138
View File
@@ -3,103 +3,47 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gestione Organizzazioni - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (tabella org/paginazione/badge) -> parita' funzionale assoluta. BI veste la chrome.
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">
<title>Gestione Organizzazioni · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css. Pagina in /admin/: asset con path ASSOLUTI. -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style>
.entity-type-badge {
display: inline-flex;
padding: 3px 10px;
border-radius: 100px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.entity-type-badge.essential { background: var(--danger-bg); color: var(--danger); }
.entity-type-badge.important { background: var(--warning-bg); color: #a16207; }
.entity-type-badge.not_applicable { background: var(--gray-100); color: var(--gray-600); }
.plan-badge {
display: inline-flex;
padding: 3px 10px;
border-radius: 100px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.plan-badge.free { background: var(--gray-100); color: var(--gray-600); }
.plan-badge.professional { background: var(--primary-bg); color: var(--primary); }
.plan-badge.enterprise { background: #f3e8ff; color: #7c3aed; }
.status-dot {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
}
.status-dot::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 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;
}
/* ── Tabella admin (v3.css non include stili tabella; markup sobrio, no CDN) ── */
.table-container { width:100%; overflow-x:auto; }
table { width:100%; border-collapse:collapse; font-size:13px; }
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; }
tbody td { padding:12px 14px; border-bottom:1px solid var(--line); vertical-align:middle; }
tbody tr:last-child td { border-bottom:0; }
tbody tr:hover { background:#F7FAFD; }
td strong { font-weight:700; }
.text-muted { color:var(--muted); }
.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.essential { background:#FBE9E9; color:#B23636; }
.entity-type-badge.important { background:#FBF1E2; color:#9A6418; }
.entity-type-badge.not_applicable { background:#EEF2F7; color:#5b6b80; }
.plan-badge.free { background:#EEF2F7; color:#5b6b80; }
.plan-badge.professional { background:#E7F0FB; color:#1F5FA8; }
.plan-badge.enterprise { background:#F0E9FB; color:#7C4DD6; }
.status-dot { display:inline-flex; align-items:center; gap:6px; font-size:.8125rem; }
.status-dot::before { content:''; width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.status-dot.active::before { background:var(--c-green); }
.status-dot.inactive::before { background:#94A3B8; }
.score-inline { display:inline-flex; align-items:center; gap:6px; font-size:.8125rem; font-weight:600; }
.score-inline .score-dot { width:8px; height:8px; border-radius:50%; }
.pagination { display:flex; align-items:center; justify-content:space-between; padding:14px 14px 2px; border-top:1px solid var(--line); margin-top:6px; }
.pagination-info { font-size:.8125rem; color:var(--muted); }
.pagination-controls { display:flex; gap:8px; }
.btn-ghost[disabled] { opacity:.5; cursor:not-allowed; }
.btn-sm { padding:6px 11px; font-size:11.5px; }
.empty-state { text-align:center; padding:60px 24px; color:var(--muted); }
.empty-state .ic { width:48px; height:48px; color:var(--line); margin:0 auto 16px; display:block; }
.empty-state h4 { font-size:1rem; font-weight:700; color:var(--ink); margin:0 0 8px; }
.empty-state p { margin:0; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -116,55 +60,44 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h1 class="h2">Gestione Organizzazioni</h1>
<div class="content-header-actions">
<span class="badge badge-danger">Super Admin</span>
</div>
</header>
<div class="content-body">
<!-- Breadcrumb -->
<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>
<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><a href="/admin/index.html">Amministrazione</a>
<i data-lucide="chevron-right" class="ic"></i><span>Organizzazioni</span>
</nav>
<div class="page-head">
<div>
<h1>Gestione Organizzazioni</h1>
<p class="sub">Tutte le organizzazioni registrate sulla piattaforma</p>
</div>
<span class="badge-t red">Super Admin</span>
</div>
<!-- Table -->
<div class="card">
<div class="card-body" style="padding:0;">
<section class="card card-pad">
<div id="orgs-container">
<div class="spinner" style="margin:60px auto;" role="status" aria-label="Caricamento in corso"></div>
</div>
</div>
</div>
<div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div>
</div>
</section>
</main>
</div>
<script src="../js/api.js?v=20260620"></script>
<script src="../js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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=20260618"></script>
<script src="../js/i18n.js?v=20260620"></script>
<!-- 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>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
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;
@@ -205,7 +138,7 @@
// ── Caricamento ──────────────────────────────────────────
async function loadOrganizations(page) {
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;
try {
@@ -231,10 +164,11 @@
if (!orgs || orgs.length === 0) {
container.innerHTML = `
<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>
<p>Le organizzazioni registrate appariranno qui.</p>
</div>`;
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
return;
}
@@ -304,10 +238,10 @@
Visualizzati ${start}-${end} di ${total} organizzazioni
</div>
<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
</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;
</button>
</div>
+85 -165
View File
@@ -3,125 +3,54 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gestione Utenti - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (tabella utenti/azioni) -> parita' funzionale assoluta. BI veste la chrome.
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">
<title>Gestione Utenti · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css. Pagina in /admin/: asset con path ASSOLUTI. -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style>
.role-badge {
display: inline-flex;
padding: 3px 10px;
border-radius: 100px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.role-badge.super_admin { background: var(--danger-bg); color: var(--danger); }
.role-badge.org_admin { background: var(--primary-bg); color: var(--primary); }
.role-badge.compliance_manager { background: var(--secondary-bg); color: #15803d; }
.role-badge.board_member { background: #f3e8ff; color: #7c3aed; }
.role-badge.auditor { background: var(--info-bg); color: var(--primary); }
.role-badge.employee { background: var(--gray-100); color: var(--gray-600); }
.active-toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
}
.active-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
width: 36px;
height: 20px;
background: var(--gray-300);
border-radius: 10px;
transition: background var(--transition-fast);
position: relative;
}
.toggle-slider::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
top: 2px;
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);
}
/* ── Tabella admin (v3.css non include stili tabella; markup sobrio, no CDN) ── */
.table-container { width:100%; overflow-x:auto; }
table { width:100%; border-collapse:collapse; font-size:13px; }
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; }
tbody td { padding:12px 14px; border-bottom:1px solid var(--line); vertical-align:middle; }
tbody tr:last-child td { border-bottom:0; }
tbody tr:hover { background:#F7FAFD; }
td strong { font-weight:700; }
.text-muted { color:var(--muted); }
.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 { 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.super_admin { background:#FBE9E9; color:#B23636; }
.role-badge.org_admin { background:#E7F0FB; color:#1F5FA8; }
.role-badge.compliance_manager { background:#E6F4EC; color:#1E7A4E; }
.role-badge.board_member { background:#F0E9FB; color:#7C4DD6; }
.role-badge.auditor { background:#E7F0FB; color:#1F5FA8; }
.role-badge.employee { background:#EEF2F7; color:#5b6b80; }
.active-toggle { position:relative; display:inline-flex; align-items:center; cursor:pointer; }
.active-toggle input { position:absolute; opacity:0; width:0; height:0; }
.toggle-slider { width:36px; height:20px; background:#CBD5E1; border-radius:10px; transition:background .15s; position:relative; }
.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); }
.active-toggle input:checked + .toggle-slider { background:var(--c-green); }
.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; }
@media (prefers-reduced-motion:reduce) { .toggle-slider, .toggle-slider::after { transition:none; } }
.org-tags { display:flex; flex-wrap:wrap; gap:4px; }
.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; }
.pagination { display:flex; align-items:center; justify-content:space-between; padding:14px 14px 2px; border-top:1px solid var(--line); margin-top:6px; }
.pagination-info { font-size:.8125rem; color:var(--muted); }
.pagination-controls { display:flex; gap:8px; }
.btn-ghost[disabled] { opacity:.5; cursor:not-allowed; }
.btn-sm { padding:6px 11px; font-size:11.5px; }
.btn-ghost .ic { color:var(--c-blue); }
.empty-state { text-align:center; padding:60px 24px; color:var(--muted); }
.empty-state .ic { width:48px; height:48px; color:var(--line); margin:0 auto 16px; display:block; }
.empty-state h4 { font-size:1rem; font-weight:700; color:var(--ink); margin:0 0 8px; }
.empty-state p { margin:0; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -138,55 +67,44 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h1 class="h2">Gestione Utenti</h1>
<div class="content-header-actions">
<span class="badge badge-danger">Super Admin</span>
</div>
</header>
<div class="content-body">
<!-- Breadcrumb -->
<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>
<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><a href="/admin/index.html">Amministrazione</a>
<i data-lucide="chevron-right" class="ic"></i><span>Utenti</span>
</nav>
<div class="page-head">
<div>
<h1>Gestione Utenti</h1>
<p class="sub">Tutti gli utenti registrati sulla piattaforma</p>
</div>
<span class="badge-t red">Super Admin</span>
</div>
<!-- Table -->
<div class="card">
<div class="card-body" style="padding:0;">
<section class="card card-pad">
<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 class="v3-loading"><span class="v3-spin"></span>Caricamento utenti…</div>
</div>
</section>
</main>
</div>
<script src="../js/api.js?v=20260620"></script>
<script src="../js/common.js?v=20260620"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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=20260618"></script>
<script src="../js/i18n.js?v=20260620"></script>
<!-- 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>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
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;
@@ -196,12 +114,12 @@
const me = await api.getMe();
if (!me.success || !me.data || me.data.role !== 'super_admin') {
showNotification('Accesso non autorizzato.', 'error');
setTimeout(() => { window.location.href = '../dashboard.html'; }, 1500);
setTimeout(() => { window.location.href = '/dashboard.html'; }, 1500);
return;
}
loadUsers(1);
} catch (e) {
window.location.href = '../dashboard.html';
window.location.href = '/dashboard.html';
}
})();
@@ -218,7 +136,7 @@
// ── Caricamento ──────────────────────────────────────────
async function loadUsers(page) {
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;
try {
@@ -244,10 +162,11 @@
if (!users || users.length === 0) {
container.innerHTML = `
<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>
<p>Gli utenti registrati appariranno qui.</p>
</div>`;
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
return;
}
@@ -306,8 +225,8 @@
<td>${formatDate(user.created_at)}</td>
<td>
${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">
<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>
? `<button class="btn-ghost btn-sm" onclick="impersonateUser(${user.id}, '${escapeHtml(user.email)}')" title="Entra come questo utente per 1 ora">
<i data-lucide="repeat" class="ic"></i>
Impersonate
</button>`
: '<span class="text-muted">-</span>'}
@@ -332,10 +251,10 @@
Visualizzati ${start}-${end} di ${total} utenti
</div>
<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
</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;
</button>
</div>
@@ -351,6 +270,7 @@
}
container.innerHTML = html;
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
}
// ── Toggle Attivo/Inattivo ───────────────────────────────
@@ -388,7 +308,7 @@
sessionStorage.setItem('nis2_impersonate_target_email', email);
// Sostituisce token con quello impersonate (no refresh per default)
api.setTokens(res.data.access_token, '');
window.location.href = '../dashboard.html';
window.location.href = '/dashboard.html';
} else {
showNotification(res.message || 'Impersonate fallito.', 'error');
}
+57 -23
View File
@@ -3,11 +3,39 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<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 -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="css/style.css?v=20260618">
<title>Architettura · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<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 ────────────────────────────────── */
.arch-section {
@@ -634,18 +662,21 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<!-- Main Content -->
<main class="main-content">
<header class="content-header">
<h2>Architettura del Sistema</h2>
<div class="content-header-actions">
<span class="badge badge-neutral">v1.0</span>
<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>Architettura</span>
</nav>
<div class="page-head">
<div>
<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>
</header>
<div class="content-body">
@@ -1088,18 +1119,21 @@
</div>
</main>
</div>
<script src="js/api.js?v=20260631"></script>
<script src="js/common.js?v=20260631"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
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>
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
loadSidebar(); // = loadTopnavV3()
if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
</script>
</body>
</html>
+270 -128
View File
@@ -3,12 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gap Analysis - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (wizard/form/gauge) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>GAP Analysis · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css. Self-host only: no external font/script hosts. -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
@@ -22,39 +20,162 @@
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- 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>
<body>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<!-- Main Content -->
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="assessment.title">Gap Analysis NIS2</h1>
<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="assessment.title">Gap Analysis NIS2</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="assessment.title">Gap Analysis NIS2</h1>
<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 btn-outline btn-sm" id="btn-ai-analyze" style="display:none;" 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 class="btn-outline" id="btn-ai-analyze" style="display:none;" onclick="aiAnalyze()">
<i data-lucide="sparkles" class="ic"></i> Analisi AI
</button>
</div>
</header>
</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>
<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>
<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;"></div>
<div id="existing-assessments" class="mb-24" style="display:none; text-align:left;"></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 class="btn-primary btn-lg" id="btn-new-assessment" onclick="createNewAssessment()">
<i data-lucide="plus" class="ic"></i> Nuovo Assessment
</button>
</div>
</div>
</section>
<!-- Wizard Container -->
<div id="assessment-wizard" style="display:none;">
@@ -62,118 +183,110 @@
<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 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 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 class="ga-prog-track">
<div id="progress-bar-fill" class="ga-prog-fill"></div>
</div>
<div style="font-size:0.7rem; color:var(--gray-400); margin-top:4px;">
<div class="ga-prog-sub">
<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">
<section class="card mb-24">
<div class="qcard-head">
<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="card-pad" 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
<div class="qcard-foot">
<button class="btn-secondary" id="btn-prev" onclick="prevQuestion()" disabled>
<i data-lucide="chevron-left" class="ic"></i> Precedente
</button>
<button class="btn btn-ghost btn-sm" onclick="saveCurrentResponse()">
<button class="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 class="btn-primary" id="btn-next" onclick="nextQuestion()">
Successiva <i data-lucide="chevron-right" class="ic"></i>
</button>
</div>
</div>
</div>
</section>
</div>
<!-- Results Container -->
<div id="assessment-results" style="display:none;">
<div class="card mb-24">
<div class="card-header">
<section class="card mb-24">
<div class="qcard-head">
<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 class="btn-outline btn-sm" onclick="aiAnalyze()">
<i data-lucide="sparkles" class="ic"></i> Analisi AI
</button>
</div>
<div class="card-body">
<div class="card-pad">
<!-- Overall Score -->
<div class="text-center mb-32" id="results-gauge"></div>
<div style="text-align:center;" class="mb-24" id="results-gauge"></div>
<!-- Category Scores -->
<h4 style="margin-bottom:16px; color:var(--gray-900);">Punteggio per Categoria</h4>
<h4 style="margin:0 0 16px; font-size:15px; font-weight:700;">Punteggio per Categoria</h4>
<div id="category-scores"></div>
</div>
</div>
</section>
<!-- AI Analysis result -->
<div class="card mb-24" id="ai-analysis-card" style="display:none;">
<div class="card-header">
<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-body" id="ai-analysis-content"></div>
</div>
<div class="card-pad" id="ai-analysis-content"></div>
</section>
<!-- Generate NCR from gaps -->
<div class="card mb-24" id="ncr-generation-card">
<div class="card-header">
<h3>Non Conformita'</h3>
<section class="card mb-24" id="ncr-generation-card">
<div class="qcard-head">
<h3>Non Conformità</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.
<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 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 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;"></div>
</div>
</div>
<div id="ncr-generation-result" style="display:none; margin-top:16px; text-align:left;"></div>
</div>
</section>
</div>
</main>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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>
<!-- 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>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
HelpSystem.init();
// ── Init chrome + i18n + help ────────────────────────────
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 ────────────────────────────────────────────────
let currentAssessmentId = null;
@@ -183,7 +296,7 @@
let currentQuestionIdx = 0;
let responses = {}; // questionId -> { answer, maturity, notes, evidence }
// ── Init ─────────────────────────────────────────────────
// ── Init (dati reali) ────────────────────────────────────
loadAssessments();
async function loadAssessments() {
@@ -193,8 +306,8 @@
const container = document.getElementById('existing-assessments');
container.style.display = 'block';
let html = '<h4 style="margin-bottom:12px; color:var(--gray-700);">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>';
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="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 => {
const statusBadge = a.status === 'completed'
@@ -204,11 +317,11 @@
<tr>
<td>${formatDate(a.created_at)}</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>
${a.status === 'completed'
? `<button class="btn btn-sm btn-ghost" onclick="viewResults('${a.id}')">Risultati</button>`
: `<button class="btn btn-sm btn-primary" onclick="resumeAssessment('${a.id}')">Riprendi</button>`
? `<button class="btn-ghost btn-sm" onclick="viewResults('${a.id}')">Risultati</button>`
: `<button class="btn-primary btn-sm" onclick="resumeAssessment('${a.id}')">Riprendi</button>`
}
</td>
</tr>`;
@@ -216,12 +329,38 @@
html += '</tbody></table></div>';
container.innerHTML = html;
refreshIcons();
// KPI sintetici (dati reali)
renderKpis(result.data);
}
} catch (e) {
// 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() {
const btn = document.getElementById('btn-new-assessment');
btn.disabled = true;
@@ -239,7 +378,8 @@
showNotification('Errore di connessione.', 'error');
} finally {
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 = `
<div style="margin-bottom:20px;">
<p style="font-size:1rem; font-weight:600; color:var(--gray-900); margin-bottom:4px;">
${escapeHtml(q.question_text || q.text || q.title || '')}
</p>
${q.guidance_it ? `<p class="text-muted" style="font-size:0.8125rem;">${escapeHtml(q.guidance_it)}</p>` : ''}
${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>` : ''}
<p class="q-text">${escapeHtml(q.question_text || q.text || q.title || '')}</p>
${q.guidance_it ? `<p class="q-guidance">${escapeHtml(q.guidance_it)}</p>` : ''}
${q.nis2_article ? `<span class="q-tag">Art. ${escapeHtml(q.nis2_article)}</span>` : ''}
${q.iso27001_control ? `<span class="q-tag">ISO ${escapeHtml(q.iso27001_control)}</span>` : ''}
</div>
<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">
${answers.map(a => `
<label class="form-radio ${r.answer === a.value ? 'selected' : ''}">
@@ -396,7 +534,7 @@
</div>
<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"
value="${r.maturity != null ? r.maturity : 0}" id="maturity-slider"
oninput="document.getElementById('maturity-value').textContent = this.value">
@@ -413,7 +551,7 @@
placeholder="Aggiungi eventuali note o osservazioni...">${escapeHtml(r.notes || '')}</textarea>
</div>
<div class="form-group">
<div class="form-group" style="margin-bottom:0;">
<label class="form-label">Descrizione Evidenza</label>
<input type="text" class="form-input" id="question-evidence"
placeholder="Documenti, procedure, strumenti a supporto..."
@@ -430,12 +568,13 @@
const isLast = currentCategoryIdx === categories.length - 1 && currentQuestionIdx === cat.questions.length - 1;
const nextBtn = document.getElementById('btn-next');
if (isLast) {
nextBtn.innerHTML = 'Completa Assessment';
nextBtn.className = 'btn btn-success';
nextBtn.innerHTML = '<i data-lucide="check" class="ic"></i> Completa Assessment';
nextBtn.className = 'btn-success';
} 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.className = 'btn btn-primary';
nextBtn.innerHTML = 'Successiva <i data-lucide="chevron-right" class="ic"></i>';
nextBtn.className = 'btn-primary';
}
refreshIcons();
}
function onAnswerChange(radio) {
@@ -477,7 +616,7 @@
const r = responses[q.id];
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;
}
@@ -570,6 +709,7 @@
document.getElementById('assessment-wizard').style.display = 'none';
document.getElementById('assessment-results').style.display = 'block';
document.getElementById('btn-ai-analyze').style.display = '';
refreshIcons();
try {
const result = await api.getAssessmentReport(currentAssessmentId);
@@ -637,29 +777,29 @@
let html = '';
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) {
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>`; });
html += '</ul></div>';
}
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>`; });
html += '</ul></div>';
}
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>`; });
html += '</ol></div>';
}
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;
@@ -681,7 +821,7 @@
const btn = document.getElementById('btn-generate-ncr');
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 {
const result = await api.createNCRsFromAssessment(currentAssessmentId);
@@ -694,29 +834,31 @@
const majorCount = (result.data.created || []).filter(n => n.severity === 'major').length;
const minorCount = total - majorCount;
resultDiv.innerHTML = `
<div style="background:var(--secondary-bg); border:1px solid #bbf7d0; border-radius:var(--border-radius); padding:16px; text-align:left;">
<strong style="color:#15803d;">${total} non conformita' generate con successo</strong>
<div style="margin-top:8px; font-size:0.8125rem; color:var(--gray-600);">
${majorCount > 0 ? `<span style="color:var(--danger); font-weight:600;">${majorCount} major</span> ` : ''}
${minorCount > 0 ? `<span style="color:var(--warning); font-weight:600;">${minorCount} minor</span>` : ''}
<div style="background:#E6F4EC; border:1px solid #bbf7d0; border-radius:10px; padding:16px;">
<strong style="color:#15803d;">${total} non conformità generate con successo</strong>
<div style="margin-top:8px; font-size:12.5px; color:var(--muted);">
${majorCount > 0 ? `<span style="color:var(--c-red); font-weight:600;">${majorCount} major</span> ` : ''}
${minorCount > 0 ? `<span style="color:var(--c-amber); font-weight:600;">${minorCount} minor</span>` : ''}
</div>
</div>`;
showNotification(`${total} non conformita' generate.`, 'success');
showNotification(`${total} non conformità generate.`, 'success');
} 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');
}
btn.style.display = 'none';
} 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');
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) {
showNotification('Errore di connessione.', 'error');
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>
+310 -53
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Inventario - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (tabelle/modali/form) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Inventario · NIS2 Agile</title>
<!-- 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/modali/form/badge). È self-hostato (nessun CDN). v3.css governa chrome + shell. -->
<style>
/* ── Stats Cards ────────────────────────────────────────── */
.asset-stats {
@@ -105,6 +105,13 @@
.data-table tr:last-child td { border-bottom: none; }
.data-table tbody tr { cursor: pointer; transition: background var(--transition-fast); }
.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-badge {
@@ -273,18 +280,26 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="assets.title">Inventario</h1>
<div class="content-header-actions">
<span class="text-muted" style="font-size:0.8rem; margin-right:8px;">Art. 21.2.i NIS2</span>
<button class="btn btn-secondary" onclick="openImportModal()" title="Importa beni da CSV/CMDB">Importa</button>
<button class="btn btn-primary" onclick="showCreateAssetModal()">+ Nuovo bene</button>
<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="assets.title">Inventario</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="assets.title">Inventario</h1>
<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>
</header>
<div class="content-body">
<!-- Stats Cards -->
@@ -359,28 +374,22 @@
</div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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>
<!-- 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>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
I18n.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")
// ── Labels ──────────────────────────────────────────────
@@ -442,6 +451,33 @@
else showNotification((r && r.message) || 'Errore.', '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 ───────────────────────────────────────────────
let allAssets = [];
@@ -506,8 +542,57 @@
).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) {
const container = document.getElementById('assets-container');
lastView = assets || [];
if (!assets || assets.length === 0) {
container.innerHTML = `
@@ -519,23 +604,16 @@
return;
}
const sortedAssets = sortAssetsView(assets);
let html = `
<table class="data-table">
<thead>
<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>
<tr>${ASSET_COLUMNS.map(sortableTh).join('')}<th>Azioni</th></tr>
</thead>
<tbody>`;
assets.forEach(asset => {
sortedAssets.forEach(asset => {
const crit = asset.criticality || 'medium';
const st = asset.status || 'active';
html += `
@@ -554,6 +632,9 @@
<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>
</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>
</tr>`;
});
@@ -601,19 +682,24 @@
Il punteggio aggiorna automaticamente anche la criticita del bene.</p>`;
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>`;
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;">
<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>
<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>`;
}
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, {
size: 'lg',
@@ -628,9 +714,23 @@
function updateScorePreview() {
let total = 0, complete = true;
let rows = '';
document.querySelectorAll('.score-criterion').forEach(s => {
if (!s.value) { complete = false; return; }
total += parseInt(s.selectedOptions[0].dataset.pts || '0', 10);
const meaningEl = document.getElementById('meaning-' + s.dataset.key);
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';
const color = relevanceClassColors[cls];
@@ -638,6 +738,10 @@
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.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) {
@@ -682,7 +786,10 @@
try { deps = JSON.parse(deps); } catch(e) { deps = []; }
}
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',
footer: `
<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-primary" onclick="closeModal(); showEditAssetModal(${a.id})">Modifica</button>
`
@@ -818,6 +926,15 @@
`<option value="${val}" ${a.status === val ? 'selected' : ''}>${label}</option>`
).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
window._assetSaveCallback = onSave;
@@ -832,7 +949,11 @@
<select class="form-select" id="asset-voce" onchange="onVoceChange()">${voceOpts}</select>
</div>
<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>
</div>
</div>
@@ -894,6 +1015,11 @@
<input type="date" class="form-input" id="asset-warranty" value="${a.warranty_expiry || ''}">
</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',
footer: `
@@ -928,11 +1054,66 @@
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) {
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 ────────────────────────────────────────
loadAssets();
@@ -958,13 +1139,32 @@ function impFile(e){
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);
}
// 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){
const lines=text.trim().split(/\r?\n/).filter(l=>l.trim());
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());
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(); });
const a={name:o.name, asset_type:o.asset_type, criticality:o.criticality,
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');}
}
/* ── 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>
</body>
</html>
+195 -156
View File
@@ -3,65 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Calendario scadenze - NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<style>
.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>
<title>Calendario · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css. Self-host only: no external font/script hosts. -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
@@ -75,94 +20,189 @@
<meta name="application-name" content="NIS2 Agile">
<script src="/js/pwa.js?v=20260614" defer></script>
<!-- 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>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<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>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<!-- KPI sintetici -->
<div class="cal-summary" id="cal-summary" aria-live="polite"></div>
<!-- Barra: navigazione mese + selettore vista -->
<div class="cal-bar">
<div class="cal-nav">
<button class="btn btn-outline-primary btn-sm" type="button" onclick="calPrevMonth()" aria-label="Mese precedente">&lsaquo;</button>
<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>
<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="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>
<!-- Filtri -->
<div class="cal-filters">
<h4>Filtra per tipo</h4>
<div class="cal-app">
<!-- Sidebar: KPI + legenda/filtri categoria + stato -->
<aside class="cal-side">
<div class="cal-kpis" id="cal-summary" aria-live="polite"></div>
<div class="legend">
<p>Filtra per tipo</p>
<div class="cal-chips" id="cal-type-chips"></div>
<div class="cal-filter-row">
<h4>Filtra per stato</h4>
</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>
<!-- Vista griglia mensile -->
<section id="pane-grid" role="tabpanel" aria-labelledby="view-grid" aria-live="polite">
<table class="cal-grid">
<thead>
<tr id="cal-weekhead"></tr>
</thead>
<tbody id="cal-grid-body"></tbody>
</table>
<section id="pane-grid" class="mv" role="tabpanel" aria-labelledby="view-grid" aria-live="polite">
<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>
</section>
<!-- Vista lista -->
<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>
</main>
</div>
<!-- Popover eventi di un giorno -->
<div class="cal-pop" id="cal-pop" role="dialog" aria-modal="true" aria-labelledby="cal-pop-title">
<div class="cal-pop-card">
<div class="cal-pop-head">
<!-- Popover eventi di un giorno (modale) -->
<div class="cal-modal" id="cal-pop" role="dialog" aria-modal="true" aria-labelledby="cal-pop-title">
<div class="cal-modal-card">
<div class="cal-modal-head">
<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 class="cal-pop-body" id="cal-pop-body"></div>
<div class="cal-modal-body" id="cal-pop-body"></div>
</div>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help. La logica del
calendario (aggregatore read-only) resta inline e usa gli stessi id/funzioni. -->
<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>
'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.
* 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 = {
view: 'grid',
@@ -206,12 +246,14 @@
var day = String(d.getDate()).padStart(2, '0');
return d.getFullYear() + '-' + m + '-' + day;
}
function refreshIcons() { if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} } }
document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar();
if (typeof loadSidebar === 'function') loadSidebar(); // = loadTopnavV3()
if (window.I18n && I18n.init) I18n.init();
if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
refreshIcons();
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(); });
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 to = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + 3, 0);
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 {
var data = await api.calendarEvents({ from: CAL.rangeFrom, to: CAL.rangeTo });
CAL.events = (data && data.events) ? data.events : [];
} catch (e) {
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();
render();
@@ -242,7 +284,7 @@
var bs = (s && s.by_status) || {};
var defs = [['overdue', 'In ritardo'], ['due_soon', 'In scadenza'], ['upcoming', 'Future'], ['done', 'Assolte']];
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('');
} catch (e) { el('cal-summary').innerHTML = ''; }
}
@@ -252,12 +294,12 @@
el('cal-type-chips').innerHTML = Object.keys(TYPE_META).map(function (t) {
var m = TYPE_META[t];
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('');
el('cal-status-chips').innerHTML = Object.keys(STATUS_META).map(function (s) {
var m = STATUS_META[s];
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('');
}
function onTypeFilter() {
@@ -294,8 +336,8 @@
}
function calSetView(v) {
CAL.view = v;
el('view-grid').classList.toggle('active', v === 'grid');
el('view-list').classList.toggle('active', v === 'list');
el('view-grid').classList.toggle('on', v === 'grid');
el('view-list').classList.toggle('on', v === 'list');
el('view-grid').setAttribute('aria-selected', v === 'grid' ? 'true' : 'false');
el('view-list').setAttribute('aria-selected', v === 'list' ? 'true' : 'false');
el('pane-grid').classList.toggle('hidden', v !== 'grid');
@@ -308,10 +350,8 @@
if (CAL.view === 'grid') renderGrid(); else renderList();
}
// ── Vista griglia mensile ──
// ── Vista griglia mensile (.mv-grid del mockup) ──
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 firstOfMonth = new Date(year, month, 1);
// settimana inizia lunedi (getDay: 0=dom)
@@ -324,32 +364,31 @@
visibleEvents().forEach(function (e) { (byDay[e.date] = byDay[e.date] || []).push(e); });
var html = '';
for (var w = 0; w < 6; w++) {
html += '<tr>';
for (var d = 0; d < 7; d++) {
var cur = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + (w * 7 + d));
for (var i = 0; i < 42; i++) {
var cur = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + i);
var key = ymd(cur);
var inMonth = (cur.getMonth() === month);
var cls = inMonth ? '' : ' cal-out';
if (key === todayStr) cls += ' cal-today';
var evs = byDay[key] || [];
var dots = '';
var shown = evs.slice(0, 6);
shown.forEach(function (e) {
var cls = 'mc' + (inMonth ? '' : ' other') + (key === todayStr ? ' today' : '') + (evs.length ? ' has' : '');
var chips = '';
evs.slice(0, 3).forEach(function (e) {
var c = (TYPE_META[e.type] || {}).color || '#64748b';
var tip = escAttr((TYPE_META[e.type] || { label: e.type }).label + ' — ' + e.title);
dots += '<span class="cal-dot st-' + esc(e.status) + '" style="background:' + c + '" title="' + tip + '" onclick="calPopDay(\'' + key + '\')"></span>';
chips += '<div class="chip" title="' + tip + '"><span class="cdot" style="background:' + c + '"></span>' + esc(e.title) + '</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>';
var more = evs.length > 3 ? '<div class="more">+' + (evs.length - 3) + ' altri</div>' : '';
var click = evs.length ? ' role="button" tabindex="0" aria-label="' + escAttr(cur.getDate() + ': ' + evs.length + ' scadenze') + '" data-day="' + key + '"' : '';
html += '<div class="' + cls + '"' + click + '>' +
'<div class="dnum">' + cur.getDate() + '</div>' + chips + more + '</div>';
}
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) ──
@@ -364,8 +403,8 @@
var m = TYPE_META[e.type] || { label: e.type, color: '#64748b' };
var sm = STATUS_META[e.status] || { label: e.status };
return '<tr class="row-' + esc(e.status) + '">' +
'<td>' + esc(formatDate(e.date)) + '</td>' +
'<td><span class="type-tag" style="background:' + m.color + '22;color:' + m.color + '">' + esc(m.label) + '</span></td>' +
'<td>' + esc(calFmtDate(e.date)) + '</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><span class="st-badge st-' + esc(e.status) + '">' + esc(sm.label) + '</span></td>' +
'</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>';
}
function formatDate(s) {
function calFmtDate(s) {
var p = String(s).split('-');
if (p.length !== 3) return s;
return p[2] + '/' + p[1] + '/' + p[0];
@@ -384,15 +423,15 @@
function calPopDay(dateKey) {
var evs = visibleEvents().filter(function (e) { return e.date === dateKey; });
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) {
var m = TYPE_META[e.type] || { label: e.type, color: '#64748b' };
var sm = STATUS_META[e.status] || { label: e.status };
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>' +
'<div style="flex:1 1 auto">' +
'<span class="cdot" style="background:' + m.color + '"></span>' +
'<div style="flex:1 1 auto;min-width:0">' +
'<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>' +
'</div></div>';
}).join('');
+309 -380
View File
@@ -3,270 +3,88 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Le Mie Aziende - 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. -->
<link rel="stylesheet" href="vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="css/style.css?v=20260618">
<title>Cruscotto Studio · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Bootstrap Italia / NO CDN). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style>
.companies-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
flex-wrap: wrap;
/* ── Barra KPI (griglia 4 col responsive, riusa .stat di v3.css) ── */
.kpi-grid { display:grid; grid-template-columns:repeat(4, 1fr); gap:18px; margin-bottom:22px; }
@media (max-width:900px){ .kpi-grid{ grid-template-columns:repeat(2,1fr); } }
@media (max-width:520px){ .kpi-grid{ grid-template-columns:1fr; } }
/* ── Card aziende (non coperte da v3.css; nessun CDN) ───────────── */
.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 {
font-size: 1.5rem;
font-weight: 700;
color: var(--gray-900);
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;
}
/* Modale connettori (reskin sobrio, nessun CDN) */
.conn-row { border:1px solid var(--line); border-radius:8px; padding:12px; margin-bottom:10px; }
.conn-row input { padding:7px 9px; border:1px solid var(--line); border-radius:6px; font:inherit; font-size:13px; outline:none; }
.conn-row input:focus { border-color:#9FBEDD; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -283,29 +101,50 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<!-- Sidebar -->
<aside id="sidebar" class="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<!-- Main Content -->
<main class="main-content">
<div class="page-container">
<!-- Page Header -->
<div class="companies-header" id="nis2-companies-header">
<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>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>
<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 class="companies-header-actions">
<a href="onboarding.html" class="btn-primary" id="nis2-companies-add-btn">
<i data-lucide="plus" class="ic"></i>Aggiungi Cliente
</a>
</div>
<!-- ── Barra KPI ────────────────────────────────────────────── -->
<div class="kpi-grid" id="kpi-grid">
<div class="card card-pad stat blue" id="kpi-total">
<div class="ibox"><i data-lucide="building-2" class="ic"></i></div>
<div style="flex:1;min-width:0"><div class="lbl" data-i18n="companies.kpi_total">Aziende gestite</div><div class="val">…</div></div>
</div>
<div class="card card-pad stat green" id="kpi-avg">
<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" 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>
<input type="text" id="search-input" placeholder="Cerca azienda..." oninput="filterCompanies(this.value)" aria-label="Cerca azienda">
</div>
</div>
@@ -314,19 +153,19 @@
<!-- 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 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:6px"></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:8px;flex-shrink:0"></div>
<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:6px"></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>
@@ -339,132 +178,261 @@
</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 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>
</div>
<script src="js/api.js?v=20260631"></script>
<script src="js/common.js?v=20260631"></script>
<!-- Bootstrap Italia bundle + override sidebar BI (common-bi.js dopo common.js: ridefinisce solo loadSidebar) -->
<script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>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>
<!-- 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>
if (!checkAuth()) throw new Error('Not authenticated');
// Check that user is consultant, else redirect
if (!api.isConsultant()) {
// Accesso riservato a consulenti / super_admin (menu gated + backend gate).
var _role = api.getUserRole();
if (['consultant', 'super_admin'].indexOf(_role) === -1) {
window.location.href = 'dashboard.html';
throw new Error('Not consultant');
}
loadSidebar();
if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
// ── Data ────────────────────────────────────────────────────────
let allCompanies = [];
// ── Load companies ───────────────────────────────────────────────
async function loadCompanies() {
const result = await api.listOrganizations();
// Etichette leggibili tipi scadenza (dal contratto backend).
const DEADLINE_TYPE_LABELS = {
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 emptyState = document.getElementById('companies-empty');
const dlWrap = document.getElementById('deadlines-wrap');
if (!result.success) {
showNotification('Errore nel caricamento delle aziende.', 'error');
const result = await api.consultantPortfolio();
if (!result || !result.success) {
if (typeof showNotification === 'function') showNotification('Errore nel caricamento del cruscotto.', 'error');
grid.innerHTML = '';
emptyState.style.display = 'block';
dlWrap.innerHTML = '<div class="deadlines-empty">Impossibile caricare le scadenze.</div>';
renderKpis(null);
return;
}
const orgs = result.data || [];
allCompanies = orgs;
const d = result.data || {};
renderKpis(d.kpis || {});
if (orgs.length === 0) {
allCompanies = d.companies || [];
if (allCompanies.length === 0) {
grid.innerHTML = '';
emptyState.style.display = 'block';
return;
}
} else {
emptyState.style.display = 'none';
renderCompanies(orgs);
renderCompanies(allCompanies);
}
// Load compliance scores in background
orgs.forEach(org => loadOrgScore(org.id || org.organization_id));
renderDeadlines(d.deadlines || []);
}
// ── KPI bar ───────────────────────────────────────────────────────
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 ─────────────────────────────────────────────
function renderCompanies(orgs) {
const grid = document.getElementById('companies-grid');
grid.innerHTML = orgs.map(org => renderCompanyCard(org)).join('');
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
}
function renderCompanyCard(org) {
const orgId = org.id || org.organization_id;
const orgId = org.id;
const name = org.name || 'Azienda';
const vat = org.vat_number ? 'IT' + org.vat_number : '-';
const sector = org.sector || '';
const entityType = org.entity_type || 'not_applicable';
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 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 `
<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-avatar">${escapeHtml(initials)}</div>
<div class="company-card-info">
<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>
${entityBadge}
</div>
<!-- Score bar (loaded async) -->
<div class="company-score-row" id="score-row-${orgId}" style="display:none">
<!-- Score bar -->
<div class="company-score-row">
<span class="company-score-label">Compliance</span>
<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>
<span class="company-score-value" id="score-val-${orgId}">-</span>
<span class="company-score-value" style="color:${scoreColor}">${scoreText}</span>
</div>
<!-- Stats row (loaded async) -->
<div class="company-stats" id="stats-row-${orgId}" style="display:none">
<!-- Stats row -->
<div class="company-stats">
<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>
</div>
<div class="company-stat">
<span class="company-stat-value" id="stat-incidents-${orgId}">-</span>
<span class="company-stat-label">Incidenti attivi</span>
<span class="company-stat-value">${incidents}</span>
<span class="company-stat-label">Incidenti aperti</span>
</div>
${overdueBadge ? `<div style="margin-left:auto">${overdueBadge}</div>` : ''}
</div>
<div class="company-card-footer">
<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
</button>
<button class="btn btn-primary btn-sm" onclick="manageCompany(${orgId})">
Gestisci
<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>
<button class="btn-primary btn-sm" onclick="event.stopPropagation(); manageCompany(${orgId})">
Entra
<i data-lucide="arrow-right" class="ic"></i>
</button>
</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) ─────────────
const CONNECTOR_LABELS = {
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 }
});
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 = {};
(data.data.connectors || []).forEach(c => existing[c.connector_type] = c);
const rows = (data.data.available_types || []).map(t => {
const c = existing[t] || {};
const cfg = (c.config && typeof c.config === 'object') ? c.config : {};
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];
return `<div class="conn-row" data-type="${t}" style="border:1px solid var(--gray-200);border-radius:8px;padding:12px;margin-bottom:10px;">
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}">
<div style="display:flex;justify-content:space-between;align-items:center;">
<strong>${escapeHtml(CONNECTOR_LABELS[t] || t)}</strong>
<label style="font-size:.8rem;"><input type="checkbox" class="c-en" ${c.enabled ? 'checked' : ''}> Attivo</label>
</div>
<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-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-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 || '')}">
</div>
<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>
<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>`;
}).join('');
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
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}`);
} 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) {
const old = document.getElementById('conn-modal'); if (old) old.remove();
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 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;">
<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></div>`);
}
@@ -564,45 +532,6 @@
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) ─────────────────
function manageCompany(orgId) {
api.setOrganization(orgId);
@@ -653,7 +582,7 @@
}
// ── Init ─────────────────────────────────────────────────────────
loadCompanies();
loadPortfolio();
</script>
</body>
</html>
+95 -64
View File
@@ -3,61 +3,62 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Competenze - NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Competenze · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css. No external CDN, no Bootstrap Italia. -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<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; }
.cmp-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 18px; }
/* V3+ page-specific (componenti non coperti da v3.css): tab interni, tabella, gap card, modali. Token V3. */
.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-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.active { border-color:var(--primary,#2563eb); background:#eff6ff; color:var(--primary,#2563eb); }
.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(--c-blue); background:#EFF4FB; color:var(--c-blue); }
.cmp-panel { display:none; }
.cmp-panel.active { display:block; }
.cmp-table { width:100%; border-collapse:collapse; font-size:.88rem; }
.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 { font-size:.74rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
.cmp-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; }
.cmp-badge.global { background:#ede9fe; color:#5b21b6; }
.cmp-badge.area { background:#f1f5f9; color:#475569; }
.cmp-badge.lvl { background:#e0f2fe; color:#075985; }
.cmp-table { width:100%; border-collapse:collapse; font-size:13.5px; }
.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:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); font-weight:700; }
.cmp-badge { display:inline-block; font-size:10.5px; font-weight:700; padding:2px 8px; border-radius:6px; }
.cmp-badge.global { background:#EFE7FB; color:#5b21b6; }
.cmp-badge.area { background:#EEF2F7; color:#5b6b80; }
.cmp-badge.lvl { background:#E7F0FB; color:#1F5FA8; }
.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-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-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:#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 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 .btn, .cmp-toolbar .btn { padding:4px 9px; font-size:.76rem; }
/* Gap cards */
.gap-role { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; 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 { border:1px solid var(--line); border-radius:11px; margin-bottom:14px; overflow:hidden; }
.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-sub { font-size:.82rem; color:var(--gray-500,#6b7280); }
.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 .skill { flex:1 1 220px; font-weight:600; font-size:.9rem; }
.gap-meter { font-size:.82rem; }
.gap-pill { display:inline-block; font-size:.72rem; font-weight:800; padding:2px 9px; border-radius:20px; }
.gap-pill.ok { background:#dcfce7; color:#166534; }
.gap-pill.bad { background:#fee2e2; color:#991b1b; }
.gap-role-sub { font-size:12px; color:var(--muted); }
.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:13.5px; }
.gap-meter { font-size:12.5px; color:var(--muted); }
.gap-pill { display:inline-block; font-size:10.5px; font-weight:800; padding:2px 9px; border-radius:20px; }
.gap-pill.ok { background:#E6F4EC; color:#1E7A4E; }
.gap-pill.bad { background:#FBE9E9; color:#B23636; }
.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(--gray-500,#6b7280); }
.cmp-empty { text-align:center; padding:34px 16px; color:var(--muted); }
/* 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-dialog { background:#fff; border-radius:12px; width:100%; max-width:560px; box-shadow:0 20px 50px rgba(0,0,0,.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 h3 { margin:0; font-size:1.05rem; }
.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(--line); }
.cm-head h3 { margin:0; font-size:17px; font-weight:800; }
.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 label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; }
.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 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 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-err { color:#b91c1c; font-size:.82rem; min-height:18px; }
.cm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); }
.cm-err { color:var(--c-red); font-size:12.5px; min-height:18px; }
.cm-close { background:none; border:none; font-size:22px; line-height:1; cursor:pointer; color:#9aa8ba; }
.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>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -74,17 +75,24 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="comp.title">Competenze</h2>
<div class="content-header-actions">
<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>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>
</header>
<div class="content-body">
<div class="stack">
<section class="card card-pad">
<div class="cmp-intro">
<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.
@@ -103,7 +111,7 @@
<!-- TAB 1 — Catalogo competenze -->
<div class="cmp-panel active" id="panel-catalog">
<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>
</div>
<div id="cmp-catalog"></div>
@@ -112,13 +120,13 @@
<!-- TAB 2 — Requisiti per ruolo -->
<div class="cmp-panel" id="panel-roles">
<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>
</div>
<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>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 id="cmp-role-skills"></div>
</div>
@@ -126,7 +134,7 @@
<!-- TAB 3 — Competenze persone -->
<div class="cmp-panel" id="panel-users">
<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>
</div>
<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>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>
<button class="btn btn-primary btn-sm" onclick="cmpAddUserSkill()">Registra</button>
<button class="btn-primary" onclick="cmpAddUserSkill()">Registra</button>
</div>
<div id="cmp-user-skills"></div>
</div>
@@ -142,14 +150,14 @@
<!-- TAB 4 — Gap & azioni -->
<div class="cmp-panel" id="panel-gap">
<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>
</div>
<div id="cmp-gap"></div>
</div>
</section>
</div>
</main>
</div>
<!-- Modale competenza (crea/modifica) -->
<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-err" id="cmp-skill-err" role="alert"></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>
@@ -174,21 +182,44 @@
<input type="hidden" id="cmp-courses-skill-id">
<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>
<button class="btn btn-primary" type="button" onclick="cmpMapCourse()">Aggiungi</button>
<button class="btn-primary" type="button" onclick="cmpMapCourse()">Aggiungi</button>
</div>
<div class="cm-maplist" id="cmp-courses-list"></div>
<div class="cm-err" id="cmp-courses-err" role="alert"></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>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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/competenze.js?v=20260617"></script>
<!-- Scripts V3. competenze.js si auto-inizializza su DOMContentLoaded (checkAuth/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 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>
</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>
+41 -21
View File
@@ -3,10 +3,21 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Controlli periodici - NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Controlli periodici · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia CDN). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<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-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; }
@@ -50,16 +61,23 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="pctl.title">Controlli periodici</h2>
<div class="content-header-actions">
<button class="btn btn-primary btn-sm" onclick="pcOpen()">+ Nuovo controllo</button>
<!-- 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>Monitoraggio</span>
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="pctl.title">Controlli periodici</span>
</nav>
<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>
</header>
<div class="content-body">
<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">
<strong>Controlli periodici (monitoraggio).</strong>
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.
</div>
<div id="pc-list" aria-live="polite"></div>
</div>
</section>
</main>
</div>
<!-- Modale controllo (crea/modifica) -->
<div class="sm-overlay" id="pc-modal" role="dialog" aria-modal="true" aria-labelledby="pc-modal-title">
@@ -136,12 +153,14 @@
</div>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
<!-- 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>
'use strict';
/* Controlli periodici (control monitoring §9.1/A.8.16): controlli ricorrenti + esecuzioni (evidenza) + NC/AC. */
@@ -155,9 +174,10 @@
document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar();
if (window.I18n && I18n.init) I18n.init('it');
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(); } });
['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 = []; }
+185 -334
View File
@@ -3,274 +3,114 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>AI Cross-Analysis — NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (chat/portfolio/storico) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>AI Cross-Analysis · NIS2 Agile</title>
<!-- 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>
:root {
--nis2-cyan: #06B6D4;
--nis2-cyan-light: #ecfeff;
--l4-purple: #7c3aed;
--l4-purple-bg: #f5f3ff;
/* compat token (ex style.css, niente CDN) */
--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;--danger:#B42318;--danger-bg:#FEECEB;--warning:#B45309;--secondary:#16794A;
--border-radius:10px;--border-radius-sm:7px;--border-radius-lg:14px;
--transition:.18s ease;--font-family:inherit;
}
/* Icone sprite BI: dimensione coerente inline */
.ico { width: 1em; height: 1em; vertical-align: -0.125em; fill: currentColor; }
.ico-lg { width: 2.5rem; height: 2.5rem; }
/* Icone inline (Lucide stroke) renderizzate via stringa: stile coerente inline */
.ico { width:1em; height:1em; vertical-align:-0.125em; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
.ico-lg { width:2.5rem; height:2.5rem; }
/* badge testuali pagina */
.privacy-badge { display:inline-flex; align-items:center; gap:5px; background:#d1fae5; color:#065f46; border-radius:20px; padding:3px 10px; font-size:.72rem; font-weight:700; }
.role-badge { display:inline-flex; align-items:center; gap:5px; background:var(--l4-purple-bg); color:var(--l4-purple); font-size:.75rem; font-weight:600; padding:4px 10px; border-radius:12px; }
/* ── Chat container ─────────────────────────── */
.chat-wrap {
display: flex;
flex-direction: column;
height: calc(100vh - 200px);
min-height: 500px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.chat-input-area {
border-top: 1px solid var(--gray-200);
padding: 16px;
background: var(--card-bg);
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg);
}
.chat-input-row {
display: flex;
gap: 10px;
align-items: flex-end;
}
.chat-wrap { display:flex; flex-direction:column; height:calc(100vh - 320px); min-height:460px; }
.chat-messages { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:14px; }
.chat-input-area { border-top:1px solid var(--gray-200); padding:16px; background:var(--card-bg); border-radius:0 0 var(--border-radius-lg) var(--border-radius-lg); }
.chat-input-row { display:flex; gap:10px; align-items:flex-end; }
.chat-input-row textarea {
flex: 1;
min-height: 56px;
max-height: 140px;
resize: vertical;
padding: 10px 14px;
border: 1.5px solid var(--gray-300);
border-radius: var(--border-radius);
font-family: var(--font-family);
font-size: 0.9rem;
line-height: 1.5;
transition: border-color var(--transition);
flex:1; min-height:56px; max-height:140px; resize:vertical; padding:10px 14px;
border:1.5px solid var(--gray-300); border-radius:var(--border-radius); font:inherit; font-size:.9rem; line-height:1.5; transition:border-color var(--transition);
}
.chat-input-row textarea:focus {
outline: none;
border-color: var(--l4-purple);
}
.btn-send {
background: var(--l4-purple);
color: white;
border: none;
border-radius: var(--border-radius);
padding: 14px 18px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
flex-shrink: 0;
}
.btn-send:hover:not(:disabled) { background: #6d28d9; }
.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-send:focus-visible { outline: 3px solid #fff; outline-offset: 2px; box-shadow: 0 0 0 5px var(--l4-purple); }
.chat-input-row textarea:focus { outline:none; border-color:var(--l4-purple); }
.btn-send { background:var(--l4-purple); color:#fff; border:none; border-radius:var(--border-radius); padding:14px 18px; cursor:pointer; font-size:1rem; transition:background .2s; flex-shrink:0; }
.btn-send:hover:not(:disabled) { background:#6d28d9; }
.btn-send:disabled { opacity:.5; cursor:not-allowed; }
.btn-send:focus-visible { outline:3px solid #fff; outline-offset:2px; box-shadow:0 0 0 5px var(--l4-purple); }
/* ── Message bubbles ────────────────────────── */
.msg {
display: flex;
gap: 10px;
align-items: flex-start;
}
.msg.user { flex-direction: row-reverse; }
.msg-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
flex-shrink: 0;
}
.msg.user .msg-avatar { background: var(--l4-purple); color: white; }
.msg.ai .msg-avatar { background: var(--gray-800); color: white; }
.msg-body {
max-width: 78%;
flex: 1;
}
.msg.user .msg-body { display: flex; justify-content: flex-end; }
.msg-bubble {
padding: 10px 14px;
border-radius: var(--border-radius);
font-size: 0.88rem;
line-height: 1.55;
}
.msg.user .msg-bubble {
background: var(--l4-purple);
color: white;
border-radius: var(--border-radius) 0 var(--border-radius) var(--border-radius);
}
.msg.ai .msg-bubble {
background: var(--gray-100);
color: var(--gray-800);
border-radius: 0 var(--border-radius) var(--border-radius) var(--border-radius);
width: 100%;
}
.msg { display:flex; gap:10px; align-items:flex-start; }
.msg.user { flex-direction:row-reverse; }
.msg-avatar { width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:.85rem; flex-shrink:0; }
.msg.user .msg-avatar { background:var(--l4-purple); color:#fff; }
.msg.ai .msg-avatar { background:var(--gray-800); color:#fff; }
.msg-body { max-width:78%; flex:1; }
.msg.user .msg-body { display:flex; justify-content:flex-end; }
.msg-bubble { padding:10px 14px; border-radius:var(--border-radius); font-size:.88rem; line-height:1.55; }
.msg.user .msg-bubble { background:var(--l4-purple); color:#fff; border-radius:var(--border-radius) 0 var(--border-radius) var(--border-radius); }
.msg.ai .msg-bubble { background:var(--gray-100); color:var(--gray-800); border-radius:0 var(--border-radius) var(--border-radius) var(--border-radius); width:100%; }
/* ── AI response structured ─────────────────── */
.ai-answer { margin-bottom: 12px; font-size: 0.88rem; line-height: 1.6; }
.ai-section { margin-top: 12px; }
.ai-section-title {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--gray-500);
margin-bottom: 5px;
}
.ai-section ul {
margin: 0; padding: 0 0 0 16px;
font-size: 0.84rem;
color: var(--gray-700);
}
.ai-section ul li { margin-bottom: 3px; }
.ai-benchmark {
background: var(--l4-purple-bg);
border-left: 3px solid var(--l4-purple);
padding: 8px 12px;
border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0;
font-size: 0.82rem;
color: var(--gray-700);
margin-top: 10px;
}
.ai-privacy-note {
background: #fef3c7;
border-left: 3px solid var(--warning);
padding: 6px 10px;
border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0;
font-size: 0.78rem;
color: #92400e;
margin-top: 8px;
}
.msg-time {
font-size: 0.68rem;
color: var(--gray-400);
margin-top: 3px;
text-align: right;
}
.msg.ai .msg-time { text-align: left; }
.ai-answer { margin-bottom:12px; font-size:.88rem; line-height:1.6; }
.ai-section { margin-top:12px; }
.ai-section-title { display:flex; align-items:center; gap:6px; font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--gray-500); margin-bottom:5px; }
.ai-section ul { margin:0; padding:0 0 0 16px; font-size:.84rem; color:var(--gray-700); }
.ai-section ul li { margin-bottom:3px; }
.ai-benchmark { background:var(--l4-purple-bg); border-left:3px solid var(--l4-purple); padding:8px 12px; border-radius:0 var(--border-radius-sm) var(--border-radius-sm) 0; font-size:.82rem; color:var(--gray-700); margin-top:10px; }
.ai-privacy-note { background:#fef3c7; border-left:3px solid var(--warning); padding:6px 10px; border-radius:0 var(--border-radius-sm) var(--border-radius-sm) 0; font-size:.78rem; color:#92400e; margin-top:8px; }
.msg-time { font-size:.68rem; color:var(--gray-400); margin-top:3px; text-align:right; }
.msg.ai .msg-time { text-align:left; }
/* ── Thinking bubble ────────────────────────── */
.thinking-bubble {
display: flex;
gap: 5px;
padding: 12px 16px;
background: var(--gray-100);
border-radius: 0 var(--border-radius) var(--border-radius) var(--border-radius);
width: fit-content;
}
.thinking-dot {
width: 7px; height: 7px;
background: var(--gray-400);
border-radius: 50%;
animation: thinkBounce 1.2s infinite;
}
.thinking-dot:nth-child(2) { animation-delay: 0.2s; }
.thinking-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes thinkBounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-8px); }
}
.thinking-bubble { display:flex; gap:5px; padding:12px 16px; background:var(--gray-100); border-radius:0 var(--border-radius) var(--border-radius) var(--border-radius); width:fit-content; }
.thinking-dot { width:7px; height:7px; background:var(--gray-400); border-radius:50%; animation:thinkBounce 1.2s infinite; }
.thinking-dot:nth-child(2) { animation-delay:.2s; }
.thinking-dot:nth-child(3) { animation-delay:.4s; }
@keyframes thinkBounce { 0%,60%,100% { transform:translateY(0); } 30% { transform:translateY(-8px); } }
/* ── Portfolio summary card ─────────────────── */
.portfolio-header {
background: linear-gradient(135deg, #4c1d95, #6d28d9);
border-radius: var(--border-radius-lg);
padding: 18px 22px;
color: white;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.portfolio-stat {
text-align: center;
}
.portfolio-stat .val { font-size: 1.8rem; font-weight: 800; color: #c4b5fd; }
.portfolio-stat .lbl { font-size: 0.68rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.04em; }
.portfolio-header { background:linear-gradient(135deg, #4c1d95, #6d28d9); border-radius:var(--border-radius-lg); padding:18px 22px; color:#fff; margin-bottom:16px; display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
.portfolio-stat { text-align:center; }
.portfolio-stat .val { font-size:1.8rem; font-weight:800; color:#c4b5fd; }
.portfolio-stat .lbl { font-size:.68rem; opacity:.7; text-transform:uppercase; letter-spacing:.04em; }
/* ── Quick questions ────────────────────────── */
.quick-qs {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.quick-q {
background: var(--l4-purple-bg);
color: var(--l4-purple);
border: 1px solid #ddd6fe;
border-radius: 20px;
padding: 5px 12px;
font-size: 0.77rem;
cursor: pointer;
transition: background 0.2s;
}
.quick-q:hover { background: #ede9fe; }
.quick-q:focus-visible { outline: 2px solid var(--l4-purple); outline-offset: 2px; }
/* ── Privacy badge ──────────────────────────── */
.privacy-badge {
display: inline-flex;
align-items: center;
gap: 5px;
background: #d1fae5;
color: #065f46;
border-radius: 20px;
padding: 3px 10px;
font-size: 0.72rem;
font-weight: 600;
}
.quick-qs { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; }
.quick-q { background:var(--l4-purple-bg); color:var(--l4-purple); border:1px solid #ddd6fe; border-radius:20px; padding:5px 12px; font:inherit; font-size:.77rem; cursor:pointer; transition:background .2s; }
.quick-q:hover { background:#ede9fe; }
.quick-q:focus-visible { outline:2px solid var(--l4-purple); outline-offset:2px; }
/* ── Empty state ────────────────────────────── */
.chat-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--gray-400);
text-align: center;
gap: 10px;
}
.chat-empty .ico { color: #c4b5fd; }
.chat-empty h4 { color: var(--gray-600); font-size: 0.95rem; }
.chat-empty p { font-size: 0.82rem; max-width: 360px; }
.chat-empty { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--gray-400); text-align:center; gap:10px; }
.chat-empty .ico { color:#c4b5fd; }
.chat-empty h4 { color:var(--gray-600); font-size:.95rem; margin:0; }
.chat-empty p { font-size:.82rem; max-width:360px; margin:0; }
/* ── Sidebar tab ────────────────────────────── */
.tab-bar { display: flex; border-bottom: 1px solid var(--gray-200); margin-bottom: 0; }
.tab-btn {
padding: 10px 18px;
border: none;
background: none;
font-size: 0.84rem;
font-weight: 600;
color: var(--gray-500);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn.active { color: var(--l4-purple); border-bottom-color: var(--l4-purple); }
.tab-btn:focus-visible { outline: 2px solid var(--l4-purple); outline-offset: -2px; }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
.tab-bar { display:flex; border-bottom:1px solid var(--gray-200); margin-bottom:0; flex-wrap:wrap; }
.tab-btn { display:inline-flex; align-items:center; gap:6px; padding:10px 18px; border:none; background:none; font:inherit; font-size:.84rem; font-weight:600; color:var(--gray-500); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .2s, border-color .2s; }
.tab-btn.active { color:var(--l4-purple); border-bottom-color:var(--l4-purple); }
.tab-btn:focus-visible { outline:2px solid var(--l4-purple); outline-offset:-2px; }
.tab-pane { display:none; }
.tab-pane.active { display:block; }
/* ── portfolio tab helper grids (ex style.css) ── */
.ca-grid2 { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:16px; }
.ca-sub { background:var(--card-bg); border:1px solid var(--gray-200); border-radius:var(--border-radius); overflow:hidden; }
.ca-sub-head { padding:12px 16px; border-bottom:1px solid var(--gray-100); }
.ca-sub-head h4 { margin:0; font-size:.9rem; }
.ca-sub-body { padding:14px 16px; }
.ca-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:12px; }
.ca-card { background:var(--card-bg); border:1px solid var(--gray-200); border-radius:var(--border-radius); padding:12px 14px; }
.ca-spinner { width:30px; height:30px; border:3px solid var(--gray-200); border-top-color:var(--l4-purple); border-radius:50%; animation:ca-spin .8s linear infinite; margin:30px auto; }
@keyframes ca-spin { to { transform:rotate(360deg); } }
.text-muted { color:var(--gray-500); }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -287,25 +127,25 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2>
<svg class="ico" aria-hidden="true" style="color:var(--l4-purple);margin-right:8px;"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-software"></use></svg>
AI Cross-Analysis
<span class="privacy-badge" style="margin-left:10px;">
<svg class="ico" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg> Dati aggregati — privacy garantita
</span>
</h2>
<div class="content-header-actions">
<span class="badge" style="background:var(--l4-purple-bg);color:var(--l4-purple);font-size:0.75rem;padding:4px 10px;border-radius:12px;">
<svg class="ico" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg> Solo Consulenti &amp; Admin
</span>
</div>
</header>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<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>AI Cross-Analysis</span>
</nav>
<div class="page-head">
<div>
<h1 style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<i data-lucide="cpu" class="ic" style="width:24px;height:24px;color:var(--l4-purple);"></i>
AI Cross-Analysis
<span class="privacy-badge"><i data-lucide="lock" class="ic"></i> Dati aggregati — privacy garantita</span>
</h1>
<p class="sub">Analisi AI aggregata sul portfolio clienti — dati anonimizzati</p>
</div>
<span class="role-badge"><i data-lucide="user" class="ic"></i> Solo Consulenti &amp; Admin</span>
</div>
<!-- Portfolio header -->
<div class="portfolio-header" id="portfolio-header">
@@ -319,13 +159,13 @@
<div class="card" style="padding:0;overflow:hidden;">
<div class="tab-bar" role="tablist" aria-label="Sezioni AI Cross-Analysis">
<button class="tab-btn active" onclick="switchTab('chat')" role="tab" aria-selected="true" aria-controls="tab-chat">
<svg class="ico" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-comment"></use></svg> Chat AI
<i data-lucide="message-circle" class="ic"></i> Chat AI
</button>
<button class="tab-btn" onclick="switchTab('history')" role="tab" aria-selected="false" aria-controls="tab-history">
<svg class="ico" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-clock"></use></svg> Storico
<i data-lucide="clock" class="ic"></i> Storico
</button>
<button class="tab-btn" onclick="switchTab('portfolio')" role="tab" aria-selected="false" aria-controls="tab-portfolio">
<svg class="ico" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-chart-line"></use></svg> Portfolio dati
<i data-lucide="line-chart" class="ic"></i> Portfolio dati
</button>
</div>
@@ -334,7 +174,7 @@
<div class="chat-wrap">
<div class="chat-messages" id="chat-messages" aria-live="polite">
<div class="chat-empty" id="chat-empty">
<svg class="ico ico-lg" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-software"></use></svg>
<i data-lucide="cpu" class="ico ico-lg"></i>
<h4>Analisi AI del tuo portfolio clienti</h4>
<p>Fai una domanda sui dati aggregati delle tue organizzazioni. I dati vengono trasmessi in forma anonima e aggregata.</p>
</div>
@@ -349,15 +189,15 @@
<button class="quick-q" onclick="setQuestion('Benchmark: dove si posiziona il mio portfolio rispetto agli standard NIS2?')">Benchmark NIS2</button>
</div>
<div class="chat-input-row">
<label for="chat-input" class="visually-hidden">Domanda all'AI sul portfolio</label>
<label for="chat-input" class="visually-hidden" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);">Domanda all'AI sul portfolio</label>
<textarea id="chat-input" placeholder="Es: Quali sono le aree più critiche nel mio portfolio clienti?" rows="2"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage();}"></textarea>
<button class="btn-send" onclick="sendMessage()" id="btn-send" aria-label="Invia domanda">
<svg class="ico" aria-hidden="true" style="color:#fff;"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-mail"></use></svg>
<i data-lucide="send" class="ic" style="color:#fff;"></i>
</button>
</div>
<div style="font-size:0.7rem;color:var(--gray-400);margin-top:6px;text-align:center;">
<svg class="ico" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-locked"></use></svg>
<div style="font-size:0.7rem;color:var(--gray-400);margin-top:6px;text-align:center;display:flex;align-items:center;gap:5px;justify-content:center;">
<i data-lucide="lock" class="ico"></i>
Dati anonimizzati e aggregati — nessun nome organizzazione inviato all'AI
</div>
</div>
@@ -367,48 +207,59 @@
<!-- TAB: HISTORY -->
<div class="tab-pane" id="tab-history" role="tabpanel">
<div style="padding:16px;" id="history-content">
<div class="spinner" style="margin:30px auto;display:block;"></div>
<div class="ca-spinner"></div>
</div>
</div>
<!-- TAB: PORTFOLIO DATI -->
<div class="tab-pane" id="tab-portfolio" role="tabpanel">
<div style="padding:16px;" id="portfolio-content">
<div class="spinner" style="margin:30px auto;display:block;"></div>
</div>
<div class="ca-spinner"></div>
</div>
</div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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');
<!-- 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>
// ── Icone inline (Lucide stroke) usate nelle stringhe HTML generate dal JS.
// Renderizzate immediatamente (no createIcons re-run), self-host, niente sprite BI. ──
const ICO_PATHS = {
'it-software': '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M20 9h2M20 14h2M2 9h2M2 14h2"/>',
'it-search': '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
'it-note': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8M16 17H8M10 9H8"/>',
'it-warning-circle': '<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>',
'it-chart-line': '<path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/>',
'it-lock': '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'it-close': '<path d="M18 6 6 18M6 6l12 12"/>',
'it-user': '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
};
// Compat con il vecchio helper ico(name, cls): emette SVG inline (stroke), dimensione via .ico/.ico-lg.
function ico(name, cls) {
const p = ICO_PATHS[name] || ICO_PATHS['it-software'];
return `<svg class="ico ${cls||''}" viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
}
</script>
<script src="/js/common-bi.js?v=20260631"></script>
<script src="/js/i18n.js?v=20260631"></script>
<script>
const SPRITE = '/vendor/bootstrap-italia/dist/svg/sprites.svg';
function ico(name, cls) { return `<svg class="ico ${cls||''}" aria-hidden="true"><use href="${SPRITE}#${name}"></use></svg>`; }
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
// ── Auth & Init ───────────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
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) {} }
let PORTFOLIO = null; // dati aggregati portfolio
let PORTFOLIO = null; // dati aggregati portfolio
// ── Init ──────────────────────────────────────────────────────────
loadPortfolio();
// ── Init ──────────────────────────────────────────────────────────
loadPortfolio();
async function loadPortfolio() {
async function loadPortfolio() {
try {
const r = await api.get('/cross-analysis/portfolio');
if (!r.success) {
@@ -435,9 +286,9 @@ async function loadPortfolio() {
document.getElementById('portfolio-header').innerHTML = `
<div style="color:white;opacity:0.7;">Errore caricamento portfolio</div>`;
}
}
}
function renderPortfolioHeader(d) {
function renderPortfolioHeader(d) {
const agg = d.aggregated || {};
const score = agg.avg_compliance_score ?? '—';
const totalRisks = agg.risks?.total_open ?? '—';
@@ -451,20 +302,20 @@ function renderPortfolioHeader(d) {
<div class="portfolio-stat"><div class="val">${totalRisks}</div><div class="lbl">Rischi aperti</div></div>
<div class="portfolio-stat"><div class="val">${trainPct}${trainPct!=='—'?'%':''}</div><div class="lbl">Formazione media</div></div>
<div class="portfolio-stat"><div class="val">${d.org_count}</div><div class="lbl">Organizzazioni</div></div>`;
}
}
function renderPortfolioTab(d) {
function renderPortfolioTab(d) {
const agg = d.aggregated;
if (!agg) {
document.getElementById('portfolio-content').innerHTML = '<p class="text-muted">Nessun dato disponibile.</p>';
return;
}
let html = '<div class="grid-2" style="gap:16px;">';
let html = '<div class="ca-grid2">';
// Settori
if (agg.by_sector && Object.keys(agg.by_sector).length) {
html += `<div class="card" style="margin:0;"><div class="card-header"><h4>Distribuzione Settoriale</h4></div><div class="card-body">`;
html += `<div class="ca-sub"><div class="ca-sub-head"><h4>Distribuzione Settoriale</h4></div><div class="ca-sub-body">`;
for (const [sec, cnt] of Object.entries(agg.by_sector)) {
const pct = Math.round(cnt / d.org_count * 100);
html += `<div style="margin-bottom:8px;">
@@ -481,7 +332,7 @@ function renderPortfolioTab(d) {
// Compliance score
if (agg.score_distribution) {
html += `<div class="card" style="margin:0;"><div class="card-header"><h4>Distribuzione Score Compliance</h4></div><div class="card-body">`;
html += `<div class="ca-sub"><div class="ca-sub-head"><h4>Distribuzione Score Compliance</h4></div><div class="ca-sub-body">`;
const colors = {'0-20%':'var(--danger)','21-40%':'#f97316','41-60%':'var(--warning)','61-80%':'#84cc16','81-100%':'var(--secondary)'};
for (const [range, cnt] of Object.entries(agg.score_distribution)) {
if (cnt === 0) continue;
@@ -497,11 +348,11 @@ function renderPortfolioTab(d) {
// Categorie NIS2 deboli
if (agg.assessments?.avg_by_category && Object.keys(agg.assessments.avg_by_category).length) {
const cats = Object.entries(agg.assessments.avg_by_category).slice(0, 5);
html += `<div class="card" style="margin:0;grid-column:1/-1;"><div class="card-header"><h4>Score Medio per Categoria NIS2 (dal più basso)</h4></div><div class="card-body">`;
html += '<div class="step-cards" style="grid-template-columns:repeat(auto-fill,minmax(180px,1fr));">';
html += `<div class="ca-sub" style="grid-column:1/-1;"><div class="ca-sub-head"><h4>Score Medio per Categoria NIS2 (dal più basso)</h4></div><div class="ca-sub-body">`;
html += '<div class="ca-cards">';
for (const [cat, score] of cats) {
const color = score < 30 ? 'var(--danger)' : score < 60 ? 'var(--warning)' : 'var(--secondary)';
html += `<div class="step-card" style="cursor:default;">
html += `<div class="ca-card">
<div style="font-size:0.75rem;color:var(--gray-500);margin-bottom:4px;">${cat}</div>
<div style="font-size:1.4rem;font-weight:800;color:${color};">${score}%</div>
<div style="height:3px;background:var(--gray-200);border-radius:2px;margin-top:6px;">
@@ -514,10 +365,10 @@ function renderPortfolioTab(d) {
html += '</div>';
document.getElementById('portfolio-content').innerHTML = html;
}
}
// ── Tab switching ─────────────────────────────────────────────────
function switchTab(tab) {
// ── Tab switching ─────────────────────────────────────────────────
function switchTab(tab) {
const tabs = ['chat','history','portfolio'];
document.querySelectorAll('.tab-btn').forEach((b, i) => {
const active = tabs[i] === tab;
@@ -527,12 +378,12 @@ function switchTab(tab) {
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
if (tab === 'history') loadHistory();
}
}
// ── Send message ──────────────────────────────────────────────────
let isThinking = false;
// ── Send message ──────────────────────────────────────────────────
let isThinking = false;
async function sendMessage() {
async function sendMessage() {
if (isThinking) return;
const input = document.getElementById('chat-input');
const question = input.value.trim();
@@ -569,9 +420,9 @@ async function sendMessage() {
document.getElementById('btn-send').disabled = false;
input.focus();
}
}
}
function appendMessage(role, text) {
function appendMessage(role, text) {
const msgs = document.getElementById('chat-messages');
const time = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit'});
const iconName = role === 'user' ? 'it-user' : 'it-software';
@@ -584,9 +435,9 @@ function appendMessage(role, text) {
</div>
</div>`;
msgs.scrollTop = msgs.scrollHeight;
}
}
function appendAIResponse(result, orgCount) {
function appendAIResponse(result, orgCount) {
const msgs = document.getElementById('chat-messages');
const time = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit'});
@@ -628,9 +479,9 @@ function appendAIResponse(result, orgCount) {
</div>
</div>`;
msgs.scrollTop = msgs.scrollHeight;
}
}
function appendError(msg) {
function appendError(msg) {
const msgs = document.getElementById('chat-messages');
msgs.innerHTML += `
<div class="msg ai">
@@ -642,9 +493,9 @@ function appendError(msg) {
</div>
</div>`;
msgs.scrollTop = msgs.scrollHeight;
}
}
function appendThinking(id) {
function appendThinking(id) {
const msgs = document.getElementById('chat-messages');
msgs.innerHTML += `
<div class="msg ai" id="${id}">
@@ -658,16 +509,16 @@ function appendThinking(id) {
</div>
</div>`;
msgs.scrollTop = msgs.scrollHeight;
}
}
function removeThinking(id) {
function removeThinking(id) {
document.getElementById(id)?.remove();
}
}
// ── History ───────────────────────────────────────────────────────
async function loadHistory() {
// ── History ───────────────────────────────────────────────────────
async function loadHistory() {
const el = document.getElementById('history-content');
el.innerHTML = '<div class="spinner" style="margin:30px auto;display:block;"></div>';
el.innerHTML = '<div class="ca-spinner"></div>';
try {
const r = await api.get('/cross-analysis/history?limit=20');
if (!r.success) { el.innerHTML = '<p class="text-muted">Errore caricamento storico.</p>'; return; }
@@ -689,23 +540,23 @@ async function loadHistory() {
} catch(e) {
el.innerHTML = '<p class="text-muted">Errore: ' + escapeHtml(e.message) + '</p>';
}
}
}
// ── Helpers ───────────────────────────────────────────────────────
function setQuestion(q) {
// ── Helpers ───────────────────────────────────────────────────────
function setQuestion(q) {
document.getElementById('chat-input').value = q;
document.getElementById('chat-input').focus();
autoResizeTextarea(document.getElementById('chat-input'));
}
}
function autoResizeTextarea(el) {
function autoResizeTextarea(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
}
}
document.getElementById('chat-input').addEventListener('input', function() {
document.getElementById('chat-input').addEventListener('input', function() {
autoResizeTextarea(this);
});
</script>
});
</script>
</body>
</html>
+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); }
+96 -315
View File
@@ -3,12 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Dashboard - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (card/gauge/stat/modali) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Dashboard · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg CDN). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260724a">
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0066CC">
@@ -24,338 +22,121 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<!-- Main Content -->
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="dashboard.title">Dashboard</h1>
<div class="content-header-actions">
<span class="text-muted" id="header-date"></span>
</div>
</header>
<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 -->
<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>Dashboard</span>
</nav>
<div class="page-head">
<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>
<h1 data-i18n="dashboard.title">Dashboard</h1>
<p class="sub">Stato di conformità NIS2 del tuo perimetro</p>
</div>
<div class="date"><i data-lucide="calendar" class="ic"></i><span id="header-date"></span></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 class="stack">
<!-- Monitor Giornaliero (eventi reali: scadenze + attività) -->
<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" aria-label="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" aria-label="Giorni successivi"><i data-lucide="chevron-right" class="ic"></i></button>
</div>
</div>
<div class="daystrip" id="daystrip"></div>
</section>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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>
<!-- 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>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
// ── Init ─────────────────────────────────────────────────
loadSidebar();
I18n.init();
HelpSystem.init();
// ── Init chrome + i18n + help ────────────────────────────
loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
if (typeof I18n !== 'undefined') I18n.init();
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
// Data corrente nell'header
document.getElementById('header-date').textContent = new Date().toLocaleDateString('it-IT', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'
});
var hd = document.getElementById('header-date');
if (hd) hd.textContent = new Date().toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
// ── Caricamento dati ─────────────────────────────────────
loadDashboard();
// Icone Lucide (chrome + pagina)
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
async function loadDashboard() {
try {
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');
}
}
// ── Dati reali ───────────────────────────────────────────
if (typeof initDashboard === 'function') initDashboard();
</script>
</body>
</html>
+1 -1
View File
@@ -73,7 +73,7 @@
</script>
<!-- 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>
const form = document.getElementById('forgot-form');
const err = document.getElementById('err');
+130 -87
View File
@@ -3,12 +3,42 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Guida all'uso - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css resta autorevole per la chrome (sidebar/widget). BI veste la guida. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Guida all'uso · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<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) ──────── */
.guide-wrap { max-width: 940px; margin: 0 auto; }
@@ -103,16 +133,20 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h1 class="h2">Guida all'uso di NIS2 Agile</h1>
<div class="content-header-actions">
<span class="text-muted">Per chi inizia da zero</span>
<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>Guida all'uso</span>
</nav>
<div class="page-head">
<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>
</header>
<div class="content-body">
<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>.
</p>
<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
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
@@ -143,7 +177,7 @@
<nav class="card card-bg shadow-sm mb-4 guide-toc-card" aria-label="Indice">
<div class="card-body">
<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
</h2>
<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 class="accordion-body">
<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
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,
@@ -194,7 +228,7 @@
<li><strong>Controllare i fornitori</strong> che hanno accesso ai propri sistemi (Art. 21, lettera d).</li>
</ol>
<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é
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
@@ -220,7 +254,7 @@
<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="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
<em>cosa fai</em> (settore) e <em>quanto sei grande</em> (dipendenti e fatturato).
</div>
@@ -265,7 +299,7 @@
<li>la <strong>Pubblica Amministrazione</strong> nei casi previsti.</li>
</ul>
<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
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
@@ -273,7 +307,7 @@
l'esenzione "piccola impresa" qui non si applica.
</div>
<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,
settore sanitario → <strong>essenziale + grande</strong> → pienamente in scope. Deve registrarsi sul portale
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
fonti da cui la piattaforma raccoglie automaticamente le evidenze di conformità.</p>
<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.
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
@@ -345,7 +379,7 @@
<li><strong>consultant</strong> — gestisce <em>più aziende clienti</em> (sezioni "Aziende" e "Connettori").</li>
</ul>
<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
consulenti/studi che gestiscono più clienti: se sei un utente singolo potresti non vederle.
</div>
@@ -366,7 +400,7 @@
<li><strong>Genera il report esecutivo</strong> dalla sezione Report — utile per il board.</li>
</ol>
<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
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.
@@ -377,7 +411,7 @@
(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>
<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
tuo settore ci sono almeno 3 organizzazioni, e non mostra mai i dati di una singola azienda.
</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 class="accordion-body">
<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".
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>.
@@ -433,7 +467,7 @@
critici. Anche SMS è meglio di nulla, app authenticator è meglio di SMS, hardware token (FIDO2)
è il top.</p></div>
<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
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.
@@ -462,7 +496,7 @@
<strong>+6 misure e +29 requisiti</strong> rispetto agli importanti.</li>
</ul>
<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
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
@@ -472,7 +506,7 @@
comunicata e applicata.
</div>
<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
(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
@@ -499,7 +533,7 @@
<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="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
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
@@ -532,7 +566,7 @@
(cita solo requisiti effettivamente esistenti).</li>
</ol>
<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
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).
@@ -551,7 +585,7 @@
<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="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
<em>potrebbero</em> accadere, con quanto è probabile e quanto farebbe male. Per ognuna decidi
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>
</ul>
<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,
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).
@@ -601,7 +635,7 @@
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>
<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,
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.
@@ -609,7 +643,7 @@
<p>Il <strong>Registro Quantitativo</strong> somma l'ALE di tutti i rischi quantificati, dando
l'esposizione economica complessiva del portafoglio.</p>
<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>
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
@@ -624,7 +658,7 @@
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>
<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
attuale 18% → semaforo <strong>ambra</strong>. Sale a 30% → <strong>rosso</strong>: intervento prioritario.
</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 class="accordion-body">
<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
(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.
@@ -660,7 +694,7 @@
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>
<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
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
@@ -677,7 +711,7 @@
<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>
<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ì.
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).
@@ -705,7 +739,7 @@
IS-1…IS-4, gravità) e fa partire i tempi Art. 23. Gli alert duplicati vengono riconosciuti e non
creano doppioni.</p>
<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>
con una API key dedicata (scope <code>ingest:incidents</code>). Trasforma il modulo incidenti da
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 class="accordion-body">
<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>
si fa una cosa in azienda. Serve sia per fare bene, sia per dimostrare alle autorità che ci hai pensato.
</div>
@@ -740,7 +774,7 @@
<li>Acceptable Use Policy (per i dipendenti).</li>
</ol>
<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
"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
@@ -753,7 +787,7 @@
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>
<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
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.
@@ -776,7 +810,7 @@
<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="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
in casa tua. NIS2 ti obbliga a valutare i fornitori "critici" (quelli con accesso ai tuoi sistemi
o dati sensibili).
@@ -791,7 +825,7 @@
<li>Inserire clausole NIS2 nel contratto (right to audit, notifica incidenti, etc.).</li>
</ol>
<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
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
@@ -805,7 +839,7 @@
<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>
<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:
7 "sì" e 1 "parziale" → punteggio 94/100, requisiti di sicurezza soddisfatti.
</div>
@@ -826,7 +860,7 @@
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>
<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
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
@@ -850,7 +884,7 @@
<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="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)
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.
@@ -863,7 +897,7 @@
almeno annuale.</li>
</ol>
<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
IT, una sessione obbligatoria di 1h per tutti i dipendenti più simulazioni di phishing trimestrali.
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 class="accordion-body">
<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.
L'inventario asset elenca tutti i sistemi, applicazioni, database e infrastrutture critiche.
</div>
@@ -911,7 +945,7 @@
<li><strong>Facility</strong>: data center, sale server.</li>
</ul>
<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,
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.
@@ -923,7 +957,7 @@
carichi un CSV (o un export dal CMDB/cloud) e la piattaforma crea gli asset calcolando
<strong>automaticamente</strong> il punteggio per ciascuno.</p>
<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>),
re-importare lo stesso asset lo <strong>aggiorna</strong> invece di duplicarlo: ideale per
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 class="accordion-body">
<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
compliance, devi poter mostrare documenti, evidenze, registri. Qui li trovi tutti.
</div>
@@ -964,17 +998,17 @@
<strong>arancio</strong> (evidenza scaduta), <strong>rosso</strong> (non conforme), grigio (non
monitorato). La copertura indica quanti controlli sono alimentati da evidenze automatiche.</p>
<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é
fotografata una volta l'anno: è il modello dei migliori strumenti internazionali.
</div>
<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>
(vedi cap. 3): senza connettori i controlli restano "non monitorati" (grigio) e la copertura è 0%.
</div>
<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
rischi e controlli, scarica l'audit log certificato. L'auditor ha tutto in 5 minuti.
</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 class="accordion-body">
<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>segnalazione anonima di vulnerabilità</strong> (prevista da NIS2, art. 12, divulgazione
coordinata) e il <strong>whistleblowing</strong> dei dipendenti (segnalazione di illeciti),
@@ -1006,7 +1040,7 @@
classificate dall'AI e (opzionalmente) risolte automaticamente.</li>
</ul>
<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
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à.
@@ -1025,7 +1059,7 @@
<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="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
è bravissima a partire dal foglio bianco, ma <strong>non sostituisce</strong> il tuo giudizio:
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>
</ul>
<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.
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à.
@@ -1140,46 +1174,55 @@
</div><!-- /guide-wrap -->
</div><!-- /content-body -->
</main>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<!-- Bootstrap Italia bundle (accordion) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
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>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
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>
// La guida è accessibile anche da non autenticati (utile per onboarding)
// ma se sei loggato carichi sidebar + i18n normalmente. Stessa logica
// della guida.html originale: ZERO modifiche backend.
if (typeof loadSidebar === 'function') loadSidebar();
// ma se sei loggato carichi chrome + i18n normalmente. ZERO modifiche backend.
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) {} }
// ── 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).
(function openFromHash() {
var h = window.location.hash;
if (!h) return;
var num = h.replace('#cap-', '');
var body = document.getElementById('body-' + num);
if (body && window.bootstrap && bootstrap.Collapse) {
bootstrap.Collapse.getOrCreateInstance(body).show();
document.getElementById('cap-' + num).scrollIntoView({ behavior: 'smooth' });
}
openChapter(h.replace('#cap-', ''), true);
})();
window.addEventListener('hashchange', function () {
var num = window.location.hash.replace('#cap-', '');
var body = document.getElementById('body-' + num);
if (body && window.bootstrap && bootstrap.Collapse) {
bootstrap.Collapse.getOrCreateInstance(body).show();
}
openChapter(window.location.hash.replace('#cap-', ''), false);
});
</script>
</body>
+31 -30
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gestione Incidenti - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (tabelle/decision-tree/modali) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Incidenti · NIS2 Agile</title>
<!-- 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/decision-tree/timeline/modali/badge). Self-hostato (nessun CDN). v3.css governa chrome + shell. -->
<style>
/* ── Filters Bar ─────────────────────────────────────── */
.filters-bar {
@@ -274,16 +274,23 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="incidents.title">Gestione Incidenti</h1>
<div class="content-header-actions">
<button class="btn btn-primary" onclick="showCreateIncidentModal()">+ Nuovo Incidente</button>
<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="incidents.title">Gestione Incidenti</span>
</nav>
<div class="page-head">
<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>
</header>
<div class="content-body">
<!-- List View -->
@@ -349,28 +356,22 @@
<div id="view-detail" class="hidden"></div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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>
<!-- 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>
// ── Auth & Init ──────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
I18n.init();
HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
// ── 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)
'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)
'consultant' => 'ConsultantController', // Cruscotto Studio — vista d'insieme aziende gestite + scadenze aggregate
'stakeholders' => 'StakeholderController', // Epic C / C5 — Registro stakeholder + matrice di Mendelow
'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)
@@ -114,6 +115,8 @@ $controllerMap = [
'services' => 'ServicesController',
'invites' => 'InviteController',
'webhooks' => 'WebhookController',
'discovery-connectors' => 'DiscoveryConnectorController', // Connettori discovery rete/cloud → auto-popola Inventario
'institutional-docs' => 'InstitutionalDocsController', // Documenti istituzionali per-org (dashboard) — ticket #499
'whistleblowing'=> 'WhistleblowingController',
'normative' => 'NormativeController',
'cross-analysis' => 'CrossAnalysisController',
@@ -264,6 +267,11 @@ $actionMap = [
'POST:state' => 'setState', // upsert valutazione/stato per requisito (org-scoped)
],
// ── ConsultantController — Cruscotto Studio (vista d'insieme aziende gestite) ──
'consultant' => [
'GET:portfolio' => 'portfolio',
],
// ── AssessmentController ────────────────────────
'assessments' => [
'GET:list' => 'list',
@@ -322,6 +330,7 @@ $actionMap = [
// ── RiskController ──────────────────────────────
'risks' => [
'GET:list' => 'list',
'GET:derived' => 'derivedList', // #500 fase 2: rischi derivati dai requisiti LEX&ISO
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
@@ -335,6 +344,8 @@ $actionMap = [
'GET:kri' => 'listKri', // P2: dashboard KRI
'POST:kri' => 'createKri',
'PUT:kri/{subId}' => 'updateKri',
'GET:requisitoActions' => 'requisitoActionsList', // #500/Modulo Azioni: azioni sul rischio-da-requisito
'POST:requisitoActions' => 'requisitoActionsCreate',
],
// ── IncidentController ──────────────────────────
@@ -442,6 +453,8 @@ $actionMap = [
'GET:dependencyMap' => 'dependencyMap',
'GET:subclasses' => 'subclasses', // Epic C / C4 — voci + sottoclassi
'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',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
@@ -644,6 +657,7 @@ $actionMap = [
'POST:{id}/assign' => 'assign',
'POST:{id}/close' => 'close',
'GET:stats' => 'stats',
'GET:channelStatus' => 'channelStatus',
'GET:trackAnonymous' => 'trackAnonymous',
],
@@ -695,6 +709,24 @@ $actionMap = [
'ai' => [
'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) ──
+46 -17
View File
@@ -3,11 +3,17 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>NIS2 Agile — Integrazioni Esterne</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN), PRIMA di style.css cosi' il design system esistente resta autorevole -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="css/style.css?v=20260618">
<title>Integrazioni Esterne · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<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 ── */
.integ-hero {
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 th {
background: rgba(255,255,255,.04);
background: #F5F8FC;
color: var(--text-secondary);
font-weight: 600; text-align: left;
padding: .6rem .75rem;
@@ -63,10 +69,14 @@
}
.api-table td {
padding: .55rem .75rem;
border-bottom: 1px solid rgba(255,255,255,.04);
border-bottom: 1px solid var(--line);
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 {
background: rgba(6,182,212,.1);
color: var(--primary);
@@ -123,7 +133,7 @@
counter-increment: steps;
display: flex; align-items: flex-start; gap: .75rem;
padding: .65rem 0;
border-bottom: 1px solid rgba(255,255,255,.04);
border-bottom: 1px solid var(--line);
font-size: .85rem;
color: var(--text-secondary);
line-height: 1.5;
@@ -169,7 +179,7 @@
.event-list { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1.5rem; }
.event-row {
display: flex; align-items: center; gap: .75rem;
background: rgba(255,255,255,.03);
background: #F8FAFD;
border: 1px solid var(--border);
border-radius: 6px;
padding: .6rem .9rem;
@@ -221,12 +231,19 @@
</head>
<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">
<div class="page-header">
<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>Integrazioni Esterne</span>
</nav>
<div class="page-head">
<div>
<h1>Integrazioni Esterne</h1>
<p>API, webhook e connettori per sistemi GRC di terze parti</p>
<p class="sub">API, webhook e connettori per sistemi GRC di terze parti</p>
</div>
</div>
<!-- Hero -->
@@ -912,10 +929,22 @@ curl -H <span class="str">"X-API-Key: nis2_TUA_CHIAVE"</span> \
</main>
<script src="js/common.js?v=20260631"></script>
<script src="js/i18n.js?v=20260631"></script>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
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>
function showTab(id) {
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) {} }
function showTab(id) {
document.querySelectorAll('.tab-btn').forEach((b, i) => {
const ids = ['api', 'invites', 'lg231', 'webhook', 'quickstart'];
b.classList.toggle('active', ids[i] === id);
@@ -925,7 +954,7 @@ function showTab(id) {
});
// scroll to tabs
document.querySelector('.tab-nav').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
</script>
</body>
</html>
+42 -20
View File
@@ -3,10 +3,21 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Audit interni - NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Audit interni · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia CDN). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<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-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; }
@@ -68,13 +79,23 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="iaud.title">Audit interni</h2>
</header>
<div class="content-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>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">
<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),
@@ -85,11 +106,9 @@
(<strong>GV.PO-02</strong>, monitoraggio del programma di sicurezza). Strumento di supporto organizzativo, non un parere legale.
</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>
</section>
</main>
</div>
<!-- Modale crea/modifica audit -->
<div class="sm-overlay" id="aud-modal" role="dialog" aria-modal="true" aria-labelledby="aud-modal-title">
@@ -139,12 +158,14 @@
</div>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
<!-- 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>
'use strict';
/*
@@ -172,9 +193,10 @@
document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar();
if (window.I18n && I18n.init) I18n.init('it');
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(); } });
['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();
+93 -79
View File
@@ -3,49 +3,54 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Modello Organizzativo SGSI (ISO 27001) - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (wizard/SoA/tabelle) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Modello Organizzativo SGSI (ISO 27001) · NIS2 Agile</title>
<!-- 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>
.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; }
.isms-steps { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:20px; }
.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; }
.isms-step.active { border-color:var(--primary,#2563eb); background:#eff6ff; font-weight:700; }
.isms-step.done { border-color:#16a34a; }
.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; }
.isms-step.done .num { background:#16a34a; color:#fff; }
/* Stili specifici della pagina SGSI non coperti da v3.css (nessun CDN). */
.text-muted { color: var(--muted); }
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }
/* Base .btn usata dai bottoni generati via JS (es. "btn btn-sm btn-outline") */
.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; }
.btn .icon, .btn .ic { width:15px; height:15px; }
.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.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; }
.raci-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; padding:8px 0; border-bottom:1px solid var(--gray-100,#f3f4f6); }
.raci-row input, .raci-row select { padding:6px 8px; border:1px solid var(--gray-200,#e5e7eb); border-radius:6px; font-size:.85rem; }
.soa-std { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; margin-bottom:14px; overflow:hidden; }
.soa-std-head { background:var(--gray-50,#f9fafb); padding:12px 16px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; }
.isms-panel h3 { margin:0 0 12px; font-size:17px; font-weight:800; }
.isms-panel h4 { margin:16px 0 8px; font-size:14px; font-weight:700; }
.isms-disclaimer { font-size:.8rem; color:#92400e; background:#FBF1E2; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:12px 0; }
.raci-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; padding:8px 0; border-bottom:1px solid var(--line); }
.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.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-ctrl { padding:12px 0; border-top:1px solid var(--gray-100,#f3f4f6); }
.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(--line); }
.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-derived { background:#ecfdf5; color:#065f46; font-size:.68rem; padding:1px 6px; border-radius:5px; }
.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:#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-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.sel-implemented,.soa-opt.sel-verified { background:#16a34a; color:#fff; border-color:#16a34a; }
.soa-opt.sel-in_progress { background:#f59e0b; color:#fff; border-color:#f59e0b; }
.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:var(--c-green); color:#fff; border-color:var(--c-green); }
.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-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 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; }
.ck-item { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--gray-100,#f3f4f6); font-size:.9rem; }
.ck-yes { color:#16a34a; font-weight:700; } .ck-no { color:#9ca3af; }
.savehint { font-size:.78rem; color:var(--gray-400,#9ca3af); 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; }
.ck-item { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--line); font-size:.9rem; }
.ck-yes { color:var(--c-green); font-weight:700; } .ck-no { color:#9ca3af; }
.savehint { font-size:.78rem; color:var(--muted); min-height:16px; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -62,21 +67,29 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="isms.title">Modello Organizzativo SGSI (ISO 27001)</h2>
<div class="content-header-actions">
<!-- 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>SGSI (ISO 27001)</span>
</nav>
<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 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>
<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>
</header>
</div>
<div class="content-body">
<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>
@@ -87,7 +100,7 @@
<!-- STEP 1 - Contesto & Ambito (cl.4) -->
<div class="isms-panel" id="panel-0">
<div class="card"><div class="card-body">
<div class="card card-pad">
<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>
@@ -101,57 +114,57 @@
<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>
<button class="btn-primary" id="nis2-isms-btn-save-step1" onclick="saveStep1()" data-i18n="isms.save_next">Salva e continua</button>
</div>
</div>
<!-- STEP 2 - Leadership (cl.5) -->
<div class="isms-panel" id="panel-1">
<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>
<p class="text-muted" data-i18n="isms.s2.hint">Definisci i ruoli del SGSI con la matrice RACI (Responsible, Accountable, Consulted, Informed).</p>
<div id="roles-list"></div>
<div class="raci-row" style="border-top:2px solid var(--gray-200,#e5e7eb); margin-top:10px;">
<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 btn-sm btn-primary" id="nis2-isms-btn-add-role" onclick="addRole()" data-i18n="isms.s2.add">Aggiungi ruolo</button>
<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>
<button class="btn btn-outline" id="nis2-isms-btn-next-step2" style="margin-top:14px;" onclick="goStep(2)" data-i18n="isms.next">Continua</button>
</div></div>
</div>
<!-- STEP 3 - Risk (cl.6) -->
<div class="isms-panel" id="panel-2">
<div class="card"><div class="card-body">
<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 btn-primary" id="nis2-isms-btn-save-step3" onclick="saveStep3()" data-i18n="isms.save_next">Salva e continua</button>
</div></div>
<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"><div class="card-body">
<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 btn-sm btn-outline" id="nis2-isms-btn-derive-soa" onclick="deriveSoa()" data-i18n="isms.s4.derive">Ri-deriva dal NIS2</button>
<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 btn-outline" id="nis2-isms-btn-next-step4" style="margin-top:14px;" onclick="goStep(4)" data-i18n="isms.next">Continua</button>
</div></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"><div class="card-body">
<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" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);">Tipo di documento</label>
<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>
@@ -160,44 +173,45 @@
<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>
<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 btn-outline" id="nis2-isms-btn-next-step5" style="margin-top:14px;" onclick="goStep(5)" data-i18n="isms.next">Continua</button>
</div></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"><div class="card-body">
<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;" id="readiness-pct">0%</div>
<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>
</div>
</div>
</section>
</main>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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>
<!-- 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>
if (window.bootstrap && bootstrap.loadFonts) {
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
// Icone Lucide della chrome + crumb/page-head statici. L'init dati e il wiring
// (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 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>
</html>
+66 -2
View File
@@ -10,6 +10,42 @@ class NIS2API {
this.token = localStorage.getItem('nis2_access_token');
this.refreshToken = localStorage.getItem('nis2_refresh_token');
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,12 +76,23 @@ class NIS2API {
const response = await fetch(url, config);
const json = await response.json();
// Token expired - try refresh
if (response.status === 401 && this.refreshToken && !options._isRetry) {
// Token expired - try refresh. ESCLUDI gli endpoint auth stessi: un 401 su
// /auth/refresh (refresh token scaduto/invalido) NON deve ri-triggerare il
// refresh → altrimenti ricorsione infinita, doRefreshToken() non ritorna mai,
// logout()/redirect non scatta e l'utente resta su una pagina rotta ("--").
// (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();
}
}
if (!json.success && !options.silent) {
@@ -122,6 +169,7 @@ class NIS2API {
this.refreshToken = refresh;
localStorage.setItem('nis2_access_token', access);
localStorage.setItem('nis2_refresh_token', refresh);
this.scheduleTokenRefresh(); // riprogramma il refresh silenzioso sul nuovo exp
}
clearTokens() {
@@ -166,6 +214,9 @@ class NIS2API {
updateOrganization(id, data) { return this.put(`/organizations/${id}`, data); }
classifyEntity(data) { return this.post('/organizations/classify', data); }
// Cruscotto Studio (dashboard consulente) — portfolio aggregato read-only.
consultantPortfolio() { return this.get('/consultant/portfolio'); }
// ═══════════════════════════════════════════════════════════════════
// Assessments
// ═══════════════════════════════════════════════════════════════════
@@ -399,6 +450,9 @@ class NIS2API {
updateRisk(id, data) { return this.put(`/risks/${id}`, data); }
deleteRisk(id) { return this.del(`/risks/${id}`); }
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', {}); }
// FAIR quantitativo + KRI (P2)
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
listAssetSubclasses() { return this.get('/assets/subclasses'); } // C4 — voci + sottoclassi
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'); }
getAcnRequirements() { return this.get('/audit/acnRequirements'); } // requisiti ACN per org
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}`); }
updateFeedback(id, data) { return this.put(`/feedback/${id}`, data); }
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
+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 WIDGET_VERSION = '1.4.15';
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 = {
apiUrl: (script && script.dataset.apiUrl) || 'http://localhost',
apiKey: (script && script.dataset.apiKey) || '',
@@ -471,7 +475,8 @@
<option value="URGENT">Urgente</option>
</select>
</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-row">
<button class="nx-btn" id="nx-voice-btn" onclick="window._nxVoice()">🎤 Parla</button>
@@ -487,6 +492,16 @@
// Paste handler
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);
// Drag & drop
desc.addEventListener('dragover', (e) => { e.preventDefault(); desc.classList.add('drag-over'); });
@@ -613,7 +628,8 @@
btn.style.background = '#fee2e2';
recognition.onresult = (e) => {
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 = '';
};
recognition.onerror = recognition.onend = () => { btn.textContent = '🎤 Parla'; btn.style.background = ''; };
@@ -629,6 +645,13 @@
const result = document.getElementById('nx-result');
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.textContent = 'Invio in corso...';
+3 -1
View File
@@ -91,6 +91,7 @@
items: [
{ name: 'Formazione', href: 'training.html', icon: iconAcademicCap(), i18nKey: 'nav.training' },
{ 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: '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' },
@@ -136,7 +137,8 @@
var cls = isActive ? 'active' : '';
var aria = isActive ? ' aria-current="page"' : '';
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>' +
'</a>';
});
+202 -14
View File
@@ -507,11 +507,17 @@ async function _loadFirmBranding() {
// ── Versioning live (Fase 4 / G13) ──────────────────────────────────────
let _versionInfo = null;
let _loadedVersion = null; // versione con cui l'app è stata caricata (per il check aggiornamenti)
let _updateWatchStarted = false;
async function _loadVersionFooter() {
try {
const r = await fetch('/version.json?_=' + Date.now());
if (!r.ok) return;
_versionInfo = await r.json();
if (_loadedVersion === null && _versionInfo && _versionInfo.version) {
_loadedVersion = _versionInfo.version;
_startUpdateWatch();
}
const el = document.getElementById('sidebar-version');
if (el && _versionInfo.version) {
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'));
}
// ── 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() {
// Crea pulsante toggle se non esiste
if (!document.querySelector('.sidebar-toggle')) {
@@ -952,22 +1004,42 @@ function switchLang(lang) {
// ── 1. Inietta il bug-reporter Nexus ────────────────────────────────────
if (!window.__nexusWidgetLoaded) {
window.__nexusWidgetLoaded = true;
// [#419 fix] L'email NON e' nelle claims del JWT NIS2 (claims.email vuoto) → se manca,
// recuperala dal profilo via api.getMe() PRIMA di iniettare. Senza email il widget crea
// ticket con richiedente vuoto → invisibili in "Le mie segnalazioni". Fallback safe = prec.
var _injectBugReporter = function (email, name) {
var s = document.createElement('script');
s.src = 'js/bug-reporter.js?v=20260613';
s.src = 'js/bug-reporter.js?v=20260624';
s.async = true;
s.dataset.product = 'NIS2';
s.dataset.tenantId = '7';
s.dataset.apiUrl = 'https://agilehub.agile.software';
// X-API-Key per il gateway ticket AgileHub. Senza, il widget riceve 401
// "Header X-API-Key o Authorization: Bearer richiesto". Valorizzare con la
// chiave NIS2 emessa da AgileHub (prodotto NIS2, tenant 7). Inietta via
// window.NEXUS_WIDGET_API_KEY oppure hardcoda qui quando disponibile.
s.dataset.apiKey = (window.NEXUS_WIDGET_API_KEY || 'nis2_ak_dev_109b4bb54557c4ab3bdbed45');
s.dataset.userName = userName;
s.dataset.userEmail = userEmail;
s.dataset.userName = name || userName || '';
s.dataset.userEmail = email || userEmail || '';
s.dataset.userRole = userRole;
s.dataset.lang = 'it';
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" ─────────────────────────────────────────
@@ -994,10 +1066,62 @@ function switchLang(lang) {
+ "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;";
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 history = [];
var pending = false; // ARIA sta elaborando una risposta (mostra indicatore "sta scrivendo")
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) {
return String(s).replace(/[&<>"']/g, function (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">'
+ '<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>'
+ '<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;'
+ 'border:none;color:#fff;font-size:18px;cursor:pointer">&times;</button>'
+ '</div>'
@@ -1043,36 +1169,79 @@ function switchLang(lang) {
+ (list || '<div style="color:#9ca3af;font-size:13px;text-align:center;'
+ 'margin-top:24px">Ciao! Chiedimi qualcosa su NIS2: misure di '
+ '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>'
+ '<form id="ai-chat-form" style="display:flex;gap:6px;padding:10px;'
+ 'border-top:1px solid #e5e7eb;background:#fff">'
+ '<input id="ai-chat-input" type="text" placeholder="Scrivi…" '
+ 'autocomplete="off" style="flex:1;padding:10px 12px;border:1px solid '
+ '#d1d5db;border-radius:10px;font-size:14px;font-family:inherit">'
+ '<button type="submit" style="padding:10px 14px;border:none;'
+ 'border-top:1px solid #e5e7eb;background:#fff;align-items:flex-end">'
+ '<textarea id="ai-chat-input" rows="1" '
+ 'placeholder="Scrivi… (Invio per inviare, Shift+Invio per andare a capo)" '
+ 'aria-label="Scrivi un messaggio ad ARIA" autocomplete="off"'
+ (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);'
+ '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>'
+ '</button>'
+ '</form>';
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;
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');
msgs.scrollTop = msgs.scrollHeight;
}
async function onSubmit(ev) {
ev.preventDefault();
if (pending) return; // evita invii doppi mentre ARIA elabora
var input = document.getElementById('ai-chat-input');
var text = (input.value || '').trim();
if (!text) return;
history.push({ role: 'user', content: text });
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(history));
pending = true;
render();
try {
// AI nativa NIS2 (RAG con KB Multi-Livello già attivo)
var t = localStorage.getItem('nis2_access_token') || '';
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', {
method: 'POST',
headers: {
@@ -1080,7 +1249,7 @@ function switchLang(lang) {
'Authorization': t ? ('Bearer ' + t) : '',
'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 answer = (resp && resp.success && resp.data
@@ -1088,14 +1257,33 @@ function switchLang(lang) {
|| (resp && (resp.answer || resp.message || resp.text))
|| 'ARIA non ha risposto. Riprova tra poco.';
history.push({ role: 'assistant', content: answer });
try { speak(answer); } catch (e2) {} // voce naturale ARIA (se non mutata)
} catch (e) {
history.push({
role: 'assistant',
content: '⚠️ ARIA non risponde in questo momento. Riprova tra poco.'
});
}
pending = false;
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(history));
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 () {
+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.',
'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: [
@@ -599,15 +608,29 @@ const HelpSystem = (function () {
// ─── Risk Management ─────────────────────────────────────────
'risks': {
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: [
{
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',
items: [
'La <strong>matrice Probabilita\' x Impatto</strong> (5x5) classifica automaticamente i rischi in base alla loro gravita\'.',
'I livelli di probabilita\' vanno da <em>Molto Bassa</em> a <em>Molto Alta</em>.',
'I livelli di impatto vanno da <em>Trascurabile</em> a <em>Critico</em>.',
'Il punteggio di rischio risultante determina il colore e la priorita\' del rischio.'
'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.',
'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>.',
'Il tasto <strong>Inerente / Residuo</strong> sopra la matrice mostra i giudizi <strong>prima</strong> o <strong>dopo</strong> il trattamento.',
'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.',
'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.',
'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.'
]
},
{
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.'
'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.'
]
}
],
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)',
'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 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'
@@ -928,7 +933,8 @@ const HelpSystem = (function () {
{
heading: 'Mappa delle Dipendenze',
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).',
'Utile per la valutazione dell\'impatto in caso di incidente o indisponibilita\'.'
]
@@ -938,7 +944,8 @@ const HelpSystem = (function () {
items: [
'Tracciamento dello stato del bene: attivo, in manutenzione, dismesso.',
'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.',
'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: [
@@ -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': {
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>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 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)',
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 (✓).',
'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>.'
@@ -1104,7 +1166,7 @@ const HelpSystem = (function () {
items: [
'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>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).',
'Le modifiche vengono applicate automaticamente a tutti gli utenti del firm al ricaricamento della pagina.'
]
@@ -1338,28 +1400,35 @@ const HelpSystem = (function () {
// ─── Aziende (consulente) ────────────────────────────────────
'companies': {
title: 'Guida - Aziende Gestite',
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.',
title: 'Guida - Cruscotto Studio',
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: [
{
heading: 'Gestione del portafoglio clienti',
heading: 'Panoramica dello studio',
items: [
'Visualizzi l\'elenco delle <strong>aziende che gestisci</strong> con il relativo livello di compliance.',
'Puoi <strong>aggiungere</strong> una nuova azienda cliente e accedere ai suoi moduli.',
'Il pulsante <strong>"Entra"</strong> imposta l\'organizzazione attiva: tutti i moduli (rischi, incidenti, policy, fornitori...) mostrano i dati di quell\'azienda.'
'La <strong>barra KPI</strong> in alto riepiloga il portafoglio: numero di aziende, compliance media, scadenze in ritardo (in rosso) e scadenze in arrivo.',
'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 <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',
items: [
'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: [
'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',
'assets.html': 'assets',
'assets': 'assets',
'connettori-discovery.html': 'connettori-discovery',
'connettori-discovery': 'connettori-discovery',
'reports.html': 'reports',
'audit.html': 'reports',
'reports': 'reports',
@@ -1859,6 +1930,42 @@ const HelpSystem = (function () {
'</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
* nell'header della pagina corrente (se applicabile).
@@ -1883,7 +1990,9 @@ const HelpSystem = (function () {
showHelp: showHelp,
addHelpButton: addHelpButton,
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.help': { it: 'Guida', en: 'Help' },
'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.title': { it: 'Dashboard', en: 'Dashboard' },
@@ -136,6 +169,27 @@ const I18n = (function () {
'risks.accept': { it: 'Accettare', en: 'Accept' },
'risks.transfer': { it: 'Trasferire', en: 'Transfer' },
'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 ──────────────────────────────────
'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_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.' },
// ── 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 };
+8 -3
View File
@@ -61,10 +61,15 @@ function orgRender() {
el('org-stat-vacant').textContent = vacant;
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 holder = node.holder_name
? '<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 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)
? '<div class="org-children">' + node.children.map(orgNodeHtml).join('') + '</div>'
? '<div class="org-children">' + node.children.map(c => orgNodeHtml(c, seen)).join('') + '</div>'
: '';
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;
})();
+61 -43
View File
@@ -3,15 +3,36 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Knowledge Base - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (lista doc, search) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Knowledge Base · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<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 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-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; }
@@ -19,8 +40,6 @@
.kb-scope-badge.SYSTEM { background: #DBEAFE; color: #1E40AF; }
.kb-scope-badge.FIRM { background: #FEF3C7; color: #92400E; }
.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>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -37,19 +56,23 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h1 class="h2">Knowledge Base</h1>
<div class="content-header-actions">
<button class="btn btn-primary" id="btn-kb-upload">
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-upload"></use></svg>
Carica documento
<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>Knowledge Base</span>
</nav>
<div class="page-head">
<div>
<h1>Knowledge Base</h1>
<p class="sub">Documenti che l'AI usa per rispondere con citazioni precise</p>
</div>
<button class="btn-primary" id="btn-kb-upload">
<i data-lucide="upload" class="ic"></i>Carica documento
</button>
</div>
</header>
<div class="content-body">
<p class="text-muted" style="margin-top:0;">
@@ -60,7 +83,7 @@
</p>
<!-- 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="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-group">
@@ -120,7 +143,7 @@
</div>
<!-- Lista documenti -->
<div class="card kb-doc-list">
<div class="card card-pad kb-doc-list">
<div class="card-header">
<h3>Documenti caricati visibili</h3>
</div>
@@ -132,15 +155,16 @@
</div>
<!-- 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-body">
<div style="display:flex;gap:8px;">
<label for="kb-search-query" class="sr-only visually-hidden">Query di ricerca semantica</label>
<div style="display:flex;gap:8px;align-items:flex-end;">
<div style="flex:1;">
<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">
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-search"></use></svg>
Cerca
<i data-lucide="search" class="icon"></i>Cerca
</button>
</div>
<div id="kb-search-results" style="margin-top:12px;"></div>
@@ -148,31 +172,25 @@
</div>
</div>
</main>
</div>
<!-- Stessa logica JS della pagina live (parita' funzionale assoluta, zero backend). -->
<script src="/js/common.js?v=20260631"></script>
<script src="/js/api.js?v=20260631"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>
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>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → kb.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/kb.js"></script>
<script>
// Gate auth + chrome come le altre pagine -bi.
// Gate auth + chrome come le altre pagine V3.
if (typeof checkAuth === 'function' && !checkAuth()) {
// checkAuth reindirizza a login se non autenticato
} else {
if (typeof loadSidebar === 'function') loadSidebar();
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) {} }
}
</script>
</body>
+1 -1
View File
@@ -515,7 +515,7 @@ body { background: var(--bg-main); }
<div id="toast"></div>
<script src="js/api.js?v=20260631"></script>
<script src="js/api.js?v=20260730a"></script>
<script>
// ══════════════════════════════════════════════════════
// CONFIG
+2 -2
View File
@@ -94,8 +94,8 @@
</script>
<!-- Stessa logica della login attuale: ZERO modifiche backend -->
<script src="js/api.js?v=20260631"></script>
<script src="js/common.js?v=20260631"></script>
<script src="js/api.js?v=20260730a"></script>
<script src="js/common.js?v=20260729b"></script>
<script>
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>
+42 -20
View File
@@ -3,10 +3,21 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Riesame di Direzione - NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Riesame di Direzione · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia CDN). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<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-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; }
@@ -62,13 +73,23 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="mreview.title">Riesame di Direzione</h2>
</header>
<div class="content-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>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">
<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
@@ -79,11 +100,9 @@
art. 23 D.Lgs. 138/2024). Strumento di supporto e pre-audit: non sostituisce l'auditor.
</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>
</section>
</main>
</div>
<!-- Modale dettaglio / verbale -->
<div class="sm-overlay" id="mr-modal" role="dialog" aria-modal="true" aria-labelledby="mr-modal-title">
@@ -126,12 +145,14 @@
</div>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
<!-- 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>
'use strict';
/*
@@ -160,9 +181,10 @@
document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar();
if (window.I18n && I18n.init) I18n.init('it');
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(); });
const ov = el('mr-modal'); if (ov) ov.addEventListener('click', e => { if (e.target === ov) mrClose(); });
await mrLoadAll();
+77 -79
View File
@@ -3,35 +3,35 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Misure e Requisiti - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Misure e Requisiti · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style>
.mr-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:14px; font-size:.92rem; line-height:1.6; }
.mr-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
.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-stats { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:16px; }
.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-stat .n { font-size:1.5rem; font-weight:800; line-height:1; }
.mr-stat .l { font-size:.76rem; color:var(--gray-500,#6b7280); margin-top:4px; }
.mr-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.mr-toolbar label { font-weight:600; font-size:.82rem; }
.mr-toolbar select, .mr-toolbar input[type=search] { padding:8px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.88rem; }
/* V3 — specifico pagina Misure/Requisiti (non coperto da v3.css) */
.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-note { font-size:12.5px; color:#92400e; background:#FBF1E2; border:1px solid #fde68a; border-radius:10px; padding:10px 12px; margin:0 0 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-stats { display:flex; flex-wrap:wrap; gap:12px; margin-bottom:16px; }
.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 .n { font-size:27px; font-weight:800; line-height:1; color:var(--c-blue); }
.mr-stat .l { font-size:11px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:#7a8aa0; margin-top:6px; }
.mr-toolbar { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.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-group { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; margin-bottom:14px; overflow:hidden; }
.mr-group-head { background:#f8fafc; padding:11px 14px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
.mr-group-head .code { font-weight:800; color:#1e3a8a; font-size:.95rem; }
.mr-group-head .area { font-size:.78rem; color:var(--gray-500,#6b7280); }
.mr-group-head .descr { font-size:.86rem; 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.ess { background:#fef3c7; color:#92400e; }
.mr-table { width:100%; border-collapse:collapse; font-size:.85rem; }
.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 { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); background:#fff; position:sticky; top:0; }
.mr-group { border:1px solid var(--line); border-radius:11px; margin-bottom:14px; overflow:hidden; }
.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:14px; }
.mr-group-head .area { font-size:12px; color:var(--muted); }
.mr-group-head .descr { font-size:13px; color:#334155; margin-top:3px; line-height:1.5; }
.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:#FBF1E2; color:#92400e; }
.mr-table { width:100%; border-collapse:collapse; font-size:13px; }
.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: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-code-pill { display:inline-block; font-size:.7rem; 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 */
.mr-code-pill { display:inline-block; font-size:11px; font-weight:700; color:#3730a3; background:#eef2ff; border-radius:6px; padding:1px 6px; }
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:focus-visible { outline:2px solid #3730a3; outline-offset:1px; }
@@ -41,31 +41,32 @@
.mr-yn.n { color:#cbd5e1; }
.mr-row.spento { opacity:.4; }
.mr-row.spento td { font-style:italic; }
.mr-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); }
.sv-badge { display:inline-block; font-size:.72rem; font-weight:800; padding:3px 10px; border-radius:20px; border:none; cursor:pointer; }
.sv-badge.sv-todo { background:#f1f5f9; color:#475569; }
.mr-empty { text-align:center; padding:34px 16px; color:var(--muted); }
.savehint { font-size:12.5px; color:var(--muted); min-height:16px; }
.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-ko { background:#fee2e2; color:#991b1b; }
.sv-badge.sv-mid { background:#fef3c7; color:#92400e; }
.sv-badge.sv-ok { background:#dcfce7; color:#166534; }
.sv-val { font-size:.76rem; color:#64748b; margin-top:4px; max-width:300px; }
/* Modale valutazione */
.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; }
.sv-badge.sv-ko { background:#FBE9E9; color:#991b1b; }
.sv-badge.sv-mid { background:#FBF1E2; color:#92400e; }
.sv-badge.sv-ok { background:#E6F4EC; color:#166534; }
.sv-val { font-size:11.5px; color:#64748b; margin-top:4px; max-width:300px; }
/* Modale valutazione (page-owned) */
.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-dialog { background:#fff; border-radius:12px; width:100%; max-width:600px; box-shadow:0 20px 50px rgba(0,0,0,.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 h3 { margin:0; font-size:1.02rem; }
.rm-head .sub { font-size:.8rem; color:var(--gray-500,#6b7280); margin-top:4px; }
.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(--line); }
.rm-head h3 { margin:0; font-size:16px; }
.rm-head .sub { font-size:12.5px; color:var(--muted); margin-top:4px; }
.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 label { display:block; font-weight:600; font-size:.86rem; 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 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(--line); border-radius:8px; font:inherit; font-size:14px; }
.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 .pill { display:inline-block; font-size:.7rem; 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-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); }
.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:11px; font-weight:700; color:#3730a3; background:#eef2ff; border-radius:6px; padding:1px 6px; margin-right:4px; }
.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(--muted); }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -82,17 +83,24 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="mr.title">Misure e Requisiti</h2>
<div class="content-header-actions">
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js -->
<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>Lex e ISO</span>
<i data-lucide="chevron-right" class="ic"></i><span>Misure e requisiti</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="mr.title">Misure e Requisiti</h1>
<p class="sub">Elenco canonico delle misure di base ACN e dei relativi requisiti</p>
</div>
<span class="savehint" id="mr-hint" role="status" aria-live="polite"></span>
</div>
</header>
<div class="content-body">
<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.
@@ -129,9 +137,8 @@
</div>
<div id="mr-list" aria-live="polite"><div class="mr-empty">Caricamento…</div></div>
</div>
</section>
</main>
</div>
<!-- Modale valutazione del requisito (org-scoped) -->
<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>
<div class="rm-foot">
<button class="btn btn-outline" type="button" onclick="mrCloseEval()">Annulla</button>
<button class="btn btn-primary" type="button" id="mr-eval-save" onclick="mrSaveEval()">Salva</button>
<button class="btn-ghost" type="button" onclick="mrCloseEval()">Annulla</button>
<button class="btn-primary" type="button" id="mr-eval-save" onclick="mrSaveEval()">Salva</button>
</div>
</div>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
<!-- Scripts V3 (ordine ESATTO come dashboard.html) -->
<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>
'use strict';
/*
@@ -185,12 +193,9 @@
function el(id) { return document.getElementById(id); }
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).
// 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; }
// 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) {
const pols = req.policies || [];
if (pols.length) {
@@ -220,6 +225,7 @@
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar();
if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
await mrLoad();
});
@@ -230,17 +236,14 @@
MR.org_class = (data && data.org_class) || null;
MR.has_org = !!(data && data.has_org);
MR.stati = (data && data.stati) || Object.keys(STATO_META);
// indicizza i requisiti per id (per la modale)
MR.byId = {};
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';
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-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);
if (focusReq) el('mr-class').value = 'all'; // evita che la riga sia filtrata via
if (focusReq) el('mr-class').value = 'all';
mrRender();
if (focusReq) mrFocusRequisito(focusReq);
} 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) {
const row = document.getElementById('req-' + reqId);
if (!row) return;
@@ -257,14 +259,12 @@
setTimeout(function () { row.classList.remove('mr-row-focus'); }, 4000);
}
// classe effettiva selezionata (risolve "auto" sulla classe dell'org)
function mrEffectiveClass() {
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;
return sel; // important | essential
return sel;
}
// un requisito è "attivo" per la classe effettiva?
function reqActive(req, cls) {
if (!cls) return true;
return cls === 'important' ? !!req.applies_important : !!req.applies_essential;
@@ -283,9 +283,8 @@
const q = (el('mr-q').value || '').trim();
const view = el('mr-view').value;
// costruisco le righe filtrate (misura -> requisiti visibili)
let activeCount = 0;
const groupsByKey = {}; // key -> {label, descr, area, code, essOnly, rows:[]}
const groupsByKey = {};
MR.measures.forEach(function (m) {
const essOnly = !m.applies_important;
@@ -398,7 +397,6 @@
(function () {
const ov = el('mr-modal'); if (!ov) return;
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) {
if (e.key !== 'Tab' || !ov.classList.contains('open')) return;
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>
<script src="js/common.js?v=20260631"></script>
<script src="js/common.js?v=20260729b"></script>
</body>
</html>
+150 -131
View File
@@ -3,29 +3,63 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Aggiornamenti Normativi - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (card aggiornamenti/badge/modali) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Aggiornamenti Normativi · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style>
.impact-badge { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; }
.impact-critical { background: var(--danger-bg); color: var(--danger); }
.impact-high { background: #fff7ed; color: #c2410c; }
.impact-medium { background: var(--warning-bg); color: #a16207; }
.impact-low { background: var(--gray-100); color: var(--gray-600); }
.impact-informational { background: rgba(6,182,212,0.1); color: #0891b2; }
.ack-badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 600; }
.ack-done { background: var(--success-bg); color: var(--success); }
.ack-pending { background: var(--warning-bg); color: #a16207; }
.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 { border: 1px solid var(--gray-200); border-radius: var(--border-radius-lg); padding: 20px; margin-bottom: 16px; transition: all var(--transition-fast); }
.update-card:hover { border-color: var(--primary); box-shadow: 0 2px 8px rgba(6,182,212,0.1); }
.update-card.action-required { border-left: 3px solid var(--warning); }
.update-card.acked { opacity: 0.7; }
.ack-progress { height: 6px; background: var(--gray-200); border-radius: 3px; overflow: hidden; }
.ack-progress-bar { height: 100%; background: var(--primary); border-radius: 3px; transition: width 0.5s ease; }
/* V3 — specifico pagina Normative (non coperto da v3.css) */
.nrm-bars{display:grid;gap:18px}
.nrm-progress-track{height:8px;background:#EAF0F6;border-radius:99px;overflow:hidden}
.nrm-progress-fill{height:100%;background:var(--c-blue);border-radius:99px;transition:width .5s ease}
.nrm-filters{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
.nrm-select{padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:#fff;font:inherit;font-size:13px;color:var(--ink)}
.nrm-check{display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;color:var(--ink)}
.nrm-check input{accent-color:var(--c-blue)}
.update-card{border:1px solid var(--line);border-radius:11px;padding:18px;margin-bottom:14px;transition:border-color .12s,box-shadow .12s;background:#fff}
.update-card:hover{border-color:#9FBEDD;box-shadow:0 4px 12px rgba(16,40,80,.08)}
.update-card.action-required{border-left:3px solid var(--c-amber)}
.update-card.acked{opacity:.72}
.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}
.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}
.impact-critical{background:#FBE9E9;color:#B23636}
.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>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -42,47 +76,41 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<div class="page-header">
<div class="page-header-content">
<h1 class="page-title">Aggiornamenti Normativi</h1>
<p class="page-subtitle">Feed NIS2 / ACN / DORA — Presa visione obbligatoria per audit compliance</p>
</div>
<div class="page-header-actions">
<button class="btn btn-secondary" onclick="loadPending()">
<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>
Solo Pendenti
</button>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js -->
<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>Lex e ISO</span>
<i data-lucide="chevron-right" class="ic"></i><span>Normative</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="normative.title">Aggiornamenti Normativi</h1>
<p class="sub">Feed NIS2 / ACN / DORA — Presa visione obbligatoria per audit compliance</p>
</div>
<button class="btn-ghost" onclick="loadPending()"><i data-lucide="clock" class="ic"></i>Solo pendenti</button>
</div>
<!-- Stats -->
<div class="stats-grid mb-24" id="stats-grid">
<div class="stat-card"><div class="spinner"></div></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>
<!-- Ack Progress bar -->
<div class="card mb-24">
<div class="card-body" style="padding:16px 20px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="font-size:0.875rem; font-weight:600; color:var(--gray-700);">Avanzamento presa visione</span>
<span id="ack-pct" style="font-size:0.875rem; font-weight:700; color:var(--primary);">—</span>
</div>
<div class="ack-progress">
<div class="ack-progress-bar" id="ack-bar" style="width:0%"></div>
</div>
<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>
</div>
</div>
<!-- Avanzamento presa visione -->
<section class="card card-pad">
<div class="sec-head"><h3>Avanzamento presa visione</h3><span class="hint" id="ack-pct">—</span></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>
<!-- Filters -->
<div class="card mb-16">
<div class="card-body" style="padding:14px 20px;">
<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">
<!-- Filtri -->
<section class="card card-pad">
<div class="nrm-filters">
<label for="filter-source" class="visually-hidden" style="position:absolute;width:1px;height:1px;overflow:hidden">Filtra per fonte normativa</label>
<select id="filter-source" class="nrm-select" 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>
@@ -91,8 +119,8 @@
<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">
<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>
@@ -100,37 +128,30 @@
<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>
<label class="nrm-check"><input type="checkbox" id="filter-action" onchange="loadUpdates()"> Solo azione richiesta</label>
</div>
</section>
<div id="updates-container"><div class="spinner" style="margin:60px auto;"></div></div>
<div id="updates-container"><div class="v3-loading"><span class="v3-spin"></span>Caricamento…</div></div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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>
<!-- Scripts V3 (ordine ESATTO come dashboard.html) -->
<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>
// ── Auth + chrome + i18n + help ──────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
loadSidebar(); // = loadTopnavV3()
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) {} } }
const sourceLabels = {
nis2_directive: 'NIS2 Directive', dlgs_138_2024: 'D.Lgs. 138/2024',
@@ -149,30 +170,27 @@
function renderStats(d) {
document.getElementById('stats-grid').innerHTML = `
<div class="stat-card">
<div class="stat-label">Aggiornamenti Totali</div>
<div class="stat-value">${d.total_updates || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Prese Visione</div>
<div class="stat-value" style="color:var(--success);">${d.acknowledged || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Pendenti</div>
<div class="stat-value" style="color:var(--warning);">${d.pending || 0}</div>
</div>
<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>`;
<a class="card card-pad stat blue" style="cursor:default">
<div class="ibox"><i data-lucide="layers" class="ic"></i></div>
<div style="flex:1;min-width:0"><div class="lbl">Aggiornamenti totali</div><div class="val blue">${d.total_updates || 0}</div></div>
</a>
<a class="card card-pad stat green" style="cursor:default">
<div class="ibox"><i data-lucide="check-check" class="ic"></i></div>
<div style="flex:1;min-width:0"><div class="lbl">Prese visione</div><div class="val green">${d.acknowledged || 0}</div></div>
</a>
<a class="card card-pad stat amber" style="cursor:default">
<div class="ibox"><i data-lucide="clock" class="ic"></i></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>
</a>`;
document.getElementById('ack-pct').textContent = (d.ack_rate || 0) + '%';
document.getElementById('ack-bar').style.width = (d.ack_rate || 0) + '%';
refreshIcons();
}
async function loadUpdates() {
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 {
const params = new URLSearchParams();
const source = document.getElementById('filter-source').value;
@@ -185,30 +203,36 @@
const result = await api.request('GET', '/normative/list?' + params);
if (result.success) renderUpdates(result.data.updates || []);
} 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() {
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 {
const result = await api.request('GET', '/normative/pending');
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) {
const container = document.getElementById('updates-container');
if (!updates.length) {
container.innerHTML = `<div class="empty-state">
<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>
<h4>${pendingOnly ? 'Nessun aggiornamento pendente — tutto preso in visione!' : 'Nessun aggiornamento trovato'}</h4>
</div>`;
container.innerHTML = `<section class="card card-pad"><div class="v3-empty">
<i data-lucide="file-text" class="ic"></i>
<b>${pendingOnly ? 'Nessun aggiornamento pendente — tutto preso in visione!' : 'Nessun aggiornamento trovato'}</b>
<span>I prossimi aggiornamenti normativi appariranno qui.</span>
</div></section>`;
refreshIcons();
return;
}
let html = '';
let html = '<div class="stack">';
updates.forEach(u => {
const acked = u.is_acknowledged || !!(u.acknowledged_at);
const actionClass = u.action_required ? 'action-required' : '';
@@ -218,51 +242,46 @@
).join(' ');
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 style="flex:1;">
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:8px;">
<div class="nrm-card-grid">
<div style="flex:1;min-width:0;">
<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="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
? `<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>'
}
</div>
<h3 style="font-size:1rem; font-weight:700; color:var(--gray-900); margin-bottom:6px;">${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>` : ''}
<p style="font-size:0.8125rem; color:var(--gray-600); line-height:1.6; margin-bottom:10px;">${escapeHtml(u.summary)}</p>
<h3 class="nrm-uupd-h3">${escapeHtml(u.title)}</h3>
${u.reference ? `<p class="nrm-ref"><code>${escapeHtml(u.reference)}</code></p>` : ''}
<p class="nrm-summary">${escapeHtml(u.summary)}</p>
${domains ? `<div style="display:flex; gap:6px; flex-wrap:wrap;">${domains}</div>` : ''}
</div>
<div style="display:flex; flex-direction:column; gap:8px; align-items:flex-end; flex-shrink:0;">
${u.effective_date ? `<p style="font-size:0.75rem; color:var(--gray-500);">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;">
<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>
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 class="nrm-card-side">
${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-ghost"><i data-lucide="external-link" class="ic"></i>Fonte</a>` : ''}
${!acked ? `<button class="btn-primary" onclick="acknowledgeUpdate(${u.id})"><i data-lucide="check" class="ic"></i>Prendi in visione</button>` : ''}
</div>
</div>
</div>`;
});
html += '</div>';
container.innerHTML = html;
refreshIcons();
}
async function acknowledgeUpdate(id) {
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.
</p>
<div class="form-group">
<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>
</div>
`, `<button class="btn btn-primary" onclick="confirmAck(${id})">Conferma presa visione</button>
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>`);
`, { footer: `<button class="btn-primary" onclick="confirmAck(${id})">Conferma presa visione</button>
<button class="btn-ghost" onclick="closeModal()">Annulla</button>` });
}
async function confirmAck(id) {
@@ -278,7 +297,7 @@
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
// Init
// ── Init dati reali ──────────────────────────────────────
loadStats();
loadUpdates();
</script>
+2 -2
View File
@@ -494,8 +494,8 @@
</script>
<!-- Stessa logica della onboarding.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260631"></script>
<script src="js/common.js?v=20260631"></script>
<script src="js/api.js?v=20260730a"></script>
<script src="js/common.js?v=20260729b"></script>
<script>
// ── Auth check ──────────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
+96 -54
View File
@@ -3,50 +3,50 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Organigramma - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Organigramma · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css. No external CDN, no Bootstrap Italia. -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<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; }
.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-stats { display:flex; flex-wrap:wrap; gap:10px; margin-bottom: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-stat .n { font-size:1.5rem; font-weight:800; color:var(--primary,#2563eb); line-height:1; }
.org-stat .l { font-size:.78rem; color:var(--gray-500,#6b7280); margin-top:4px; }
/* V3+ page-specific (componenti non coperti da v3.css): albero + modale ruolo. Token V3. */
.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-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-stats { display:flex; flex-wrap:wrap; gap:12px; margin-bottom:18px; }
.org-stat { flex:1 1 150px; min-width:130px; background:#fff; border:1px solid var(--line); border-radius:11px; padding:13px 15px; }
.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 */
.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-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.gov { border-color:#b45309; background:#fffdf7; }
.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:#e0b25a; background:#FFFDF7; }
.org-card-main { flex:1 1 auto; min-width:0; }
.org-role-name { font-weight:700; font-size:.98rem; }
.org-holder { font-size:.85rem; color:var(--gray-600,#4b5563); margin-top:2px; }
.org-holder.vacant { color:#9ca3af; font-style:italic; }
.org-desc { font-size:.82rem; color:var(--gray-500,#6b7280); 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.gov { background:#fef3c7; color:#92400e; }
.org-role-name { font-weight:700; font-size:14px; }
.org-holder { font-size:12.5px; color:var(--muted); margin-top:3px; }
.org-holder.vacant { color:#9aa8ba; font-style:italic; }
.org-desc { font-size:12.5px; color:var(--muted); margin-top:6px; line-height:1.5; }
.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:#FBF1E2; color:#9A6418; }
.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(--gray-500,#6b7280); }
.org-empty .big { font-size:1.05rem; font-weight:700; color:var(--gray-700,#374151); margin-bottom:6px; }
.org-empty { text-align:center; padding:48px 16px; color:var(--muted); }
.org-empty .big { font-size:16px; font-weight:700; color:var(--ink); margin-bottom:6px; }
/* 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-dialog { background:#fff; border-radius:12px; width:100%; max-width:560px; box-shadow:0 20px 50px rgba(0,0,0,.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 h3 { margin:0; font-size:1.05rem; }
.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(--line); }
.om-head h3 { margin:0; font-size:17px; font-weight:800; }
.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 label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; }
.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 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 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-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-err { color:#b91c1c; font-size:.82rem; 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-err { color:var(--c-red); font-size:12.5px; min-height:18px; margin-top:2px; }
.om-close { background:none; border:none; font-size:22px; line-height:1; cursor:pointer; color:#9aa8ba; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -63,17 +63,27 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="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>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<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">
<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.
@@ -93,12 +103,12 @@
<div id="org-empty" class="org-empty" style="display:none;">
<div class="big">Nessun ruolo definito</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 btn-outline" onclick="orgScaffold()" style="margin-left:8px;">Crea struttura di base</button>
<button class="btn-primary" onclick="orgOpenModal()"><i data-lucide="plus" class="ic"></i>Crea il primo ruolo</button>
<button class="btn-ghost" onclick="orgScaffold()" style="margin-left:8px;"><i data-lucide="network" class="ic"></i>Crea struttura di base</button>
</div>
</section>
</div>
</main>
</div>
<!-- Modale crea/modifica ruolo -->
<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>
<div class="om-foot">
<button class="btn btn-outline" type="button" onclick="orgCloseModal()">Annulla</button>
<button class="btn btn-primary" type="button" id="org-f-save" onclick="orgSaveRole()">Salva</button>
<button class="btn-ghost" type="button" onclick="orgCloseModal()">Annulla</button>
<button class="btn-primary" type="button" id="org-f-save" onclick="orgSaveRole()">Salva</button>
</div>
</div>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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/organigramma.js?v=20260617"></script>
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → organigramma.js.
organigramma.js si auto-inizializza su DOMContentLoaded (checkAuth/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 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>
</html>
+34 -34
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Policy - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (tabelle/editor/modali) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Policy · NIS2 Agile</title>
<!-- 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/editor/modali/badge/tab). Self-hostato (nessun CDN). v3.css governa chrome + shell. -->
<style>
/* ── Policy Page Styles ──────────────────────────────────── */
.filters-bar {
@@ -254,20 +254,26 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="policies.title">Gestione Policy</h1>
<div class="content-header-actions">
<button id="nis2-policies-btn-ai-generate" class="btn btn-outline" onclick="openAIGenerateModal()">
<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>
Genera con AI
</button>
<button id="nis2-policies-btn-new" class="btn btn-primary" onclick="openCreateModal()">+ Nuova Policy</button>
<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="policies.title">Gestione Policy</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="policies.title">Gestione Policy</h1>
<p class="sub">Politiche di sicurezza: bozze, revisione, approvazione e template NIS2</p>
</div>
<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>
</header>
<div class="content-body" id="page-content">
<!-- Tabs -->
@@ -331,28 +337,22 @@
<div id="policy-detail-view" style="display:none;"></div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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>
<!-- 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>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
I18n.init();
HelpSystem.init();
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
// ── Labels ──────────────────────────────────────────────
const CATEGORY_LABELS = {
+84 -52
View File
@@ -3,52 +3,54 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Matrice RACI - NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Matrice RACI · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css. No external CDN, no Bootstrap Italia. -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<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; }
.raci-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 18px; }
/* V3+ page-specific (componenti non coperti da v3.css): tab interni, matrice RACI, link m2m. Token V3. */
.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-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.active { border-color:var(--primary,#2563eb); background:#eff6ff; color:var(--primary,#2563eb); }
.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(--c-blue); background:#EFF4FB; color:var(--c-blue); }
.cmp-panel { display:none; }
.cmp-panel.active { display:block; }
.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-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); }
.gap-role-sub { font-size:.82rem; color:var(--gray-500,#6b7280); }
.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(--muted); }
.gap-role-sub { font-size:12px; color:var(--muted); }
/* Matrice RACI */
.raci-matrix-wrap { overflow-x:auto; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; }
.raci-matrix { border-collapse:collapse; font-size:.84rem; 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 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-wrap { overflow-x:auto; border:1px solid var(--line); border-radius:11px; }
.raci-matrix { border-collapse:collapse; font-size:13px; min-width:100%; }
.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:#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 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 thead th.raci-corner { position:sticky; left:0; z-index:3; background:var(--gray-50,#f9fafb); text-align:left; }
.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:#FAFCFE; text-align:left; }
.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-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.has-r { background:#dcfce7; 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-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-legend { display:flex; gap:14px; flex-wrap:wrap; font-size:.78rem; margin:12px 2px 0; color:var(--gray-600,#4b5563); }
.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: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:#E6F4EC; border-color:#86efac; color:#166534; 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-i { background:#E0F2FE; border-color:#7dd3fc; color:#075985; font-weight:700; }
.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 .l-r b { background:#dcfce7; color:#166534; }
.raci-legend .l-a b { background:#fee2e2; color:#991b1b; }
.raci-legend .l-c b { background:#fef9c3; color:#854d0e; }
.raci-legend .l-i b { background:#e0f2fe; color:#075985; }
.raci-legend .l-r b { background:#E6F4EC; color:#166534; }
.raci-legend .l-a b { background:#FBE9E9; color:#991b1b; }
.raci-legend .l-c b { background:#FEF9C3; color:#854d0e; }
.raci-legend .l-i b { background:#E0F2FE; color:#075985; }
/* 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 label { font-size:.72rem; font-weight:600; color:var(--gray-500,#6b7280); }
.raci-inline select { padding:8px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.88rem; min-width:200px; }
.raci-linklist { width:100%; border-collapse:collapse; font-size:.88rem; }
.raci-linklist th, .raci-linklist td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
.raci-linklist th { font-size:.74rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
.raci-linklist .btn { padding:4px 9px; font-size:.76rem; }
.raci-arrow { color:var(--gray-400,#9ca3af); padding:0 6px; }
.raci-inline .fld label { font-size:11px; font-weight:700; color:var(--muted); }
.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:13.5px; }
.raci-linklist th, .raci-linklist td { text-align:left; padding:10px 11px; border-bottom:1px solid var(--line); }
.raci-linklist th { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); font-weight:700; }
.raci-arrow { color:#9aa8ba; padding:0 6px; }
.savehint { font-size:12px; color:var(--muted); }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -65,17 +67,24 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="raci.title">Matrice RACI</h2>
<div class="content-header-actions">
<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>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>
</header>
<div class="content-body">
<div class="stack">
<section class="card card-pad">
<div class="raci-intro">
<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.
@@ -123,20 +132,43 @@
<label id="raci-blabel" for="raci-bside">Lato B</label>
<select id="raci-bside"></select>
</div>
<button class="btn btn-primary btn-sm" onclick="raciAddLink()">Aggiungi collegamento</button>
<button class="btn-primary" onclick="raciAddLink()">Aggiungi collegamento</button>
</div>
<div id="raci-links"></div>
</div>
</section>
</div>
</main>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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/raci.js?v=20260619"></script>
<!-- Scripts V3. raci.js si auto-inizializza su DOMContentLoaded (checkAuth/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 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>
</html>
+2 -2
View File
@@ -268,8 +268,8 @@
</script>
<!-- Stessa logica della register.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260631"></script>
<script src="js/common.js?v=20260631"></script>
<script src="js/api.js?v=20260730a"></script>
<script src="js/common.js?v=20260729b"></script>
<script>
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
+127 -348
View File
@@ -3,344 +3,127 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Audit e Report - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (tabelle/modali/form) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Audit e Report · NIS2 Agile</title>
<!-- 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>
/* ── 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-nav {
display: flex;
gap: 0;
border-bottom: 2px solid var(--gray-200);
margin-bottom: 24px;
}
.tab-nav { display:flex; gap:0; border-bottom:2px solid var(--gray-200); margin-bottom:24px; flex-wrap:wrap; }
.tab-nav button {
padding: 12px 24px;
background: none;
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);
padding:12px 22px; background:none; border:none; border-bottom:2px solid transparent; margin-bottom:-2px;
color:var(--gray-500); font:inherit; font-size:.9rem; font-weight:600; cursor:pointer; transition:all var(--transition-fast);
}
.tab-nav button:hover {
color: var(--gray-700);
background: var(--gray-50);
}
.tab-nav button.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.tab-nav button:hover { color:var(--gray-700); background:var(--gray-50); }
.tab-nav button.active { color:var(--primary); border-bottom-color:var(--primary); }
.tab-nav button:focus-visible { outline:2px solid var(--primary); outline-offset:-2px; border-radius:4px 4px 0 0; }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
/* ── 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);
}
.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); }
.data-table { width:100%; border-collapse:collapse; background:var(--card-bg); border-radius:var(--border-radius); overflow:hidden; box-shadow:var(--card-shadow); }
.data-table thead { background:var(--gray-50); }
.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); }
.data-table td { padding:11px 16px; font-size:.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); }
.data-table tbody tr.clickable-row { cursor:pointer; }
/* ── Status Badges ──────────────────────────────────────── */
.status-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
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); }
.status-badge { display:inline-flex; align-items:center; padding:3px 10px; border-radius:20px; font-size:.78rem; font-weight:600; }
.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-container {
display: flex;
align-items: center;
gap: 8px;
}
.progress-bar {
flex-grow: 1;
height: 6px;
background: var(--gray-200);
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);
}
.progress-bar-container { display:flex; align-items:center; gap:8px; }
.progress-bar { flex-grow:1; height:6px; background:var(--gray-200); border-radius:3px; overflow:hidden; min-width:80px; }
.progress-bar-fill { height:100%; border-radius:3px; transition:width .3s ease; }
.progress-pct { font-size:.8rem; font-weight:700; min-width:36px; text-align:right; color:var(--gray-600); }
/* ── stat-box (monitoraggio) ────────────────────────────── */
.stat-box { padding:10px 14px; background:var(--gray-50); border-radius:8px; }
.stat-box .stat-value { font-size:1.4rem; font-weight:800; }
.stat-box .stat-label { font-size:.72rem; color:var(--gray-500); }
/* ── Report Section ─────────────────────────────────────── */
.report-container {
background: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
overflow: hidden;
}
.report-header {
background: var(--gray-800);
color: white;
padding: 32px;
text-align: center;
}
.report-header h3 {
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;
}
.report-container { background:var(--card-bg); border:1px solid var(--gray-200); border-radius:var(--border-radius); overflow:hidden; }
.report-header { background:var(--navy-nav,#142B4D); color:#fff; padding:30px; text-align:center; }
.report-header h3 { font-size:1.3rem; margin:0 0 4px; }
.report-header p { color:#cbd5e1; font-size:.85rem; margin:0; }
.report-body { padding:24px 30px; }
.report-section { margin-bottom:24px; }
.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); }
.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:800; color:var(--gray-800); line-height:1.2; }
.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-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; }
/* ── ISO Mapping ────────────────────────────────────────── */
.iso-mapping-row {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--gray-100);
}
.iso-mapping-row:last-child { border-bottom: none; }
.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;
}
.iso-mapping-row { display:flex; align-items:center; gap:16px; padding:12px 0; border-bottom:1px solid var(--gray-100); }
.iso-mapping-row:last-child { border-bottom:none; }
.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; }
.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:.78rem; font-weight:600; }
.iso-title { font-size:.85rem; color:var(--gray-500); min-width:200px; }
/* ── Pagination ─────────────────────────────────────────── */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
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);
}
.pagination { display:flex; justify-content:center; align-items:center; gap:12px; 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:inherit; font-size:.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:.4; cursor:not-allowed; }
.pagination .page-info { font-size:.85rem; color:var(--gray-500); }
/* ── Audit Log Action ───────────────────────────────────── */
.log-action {
display: inline-flex;
align-items: center;
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;
}
/* ── Audit Log ──────────────────────────────────────────── */
.log-action { display:inline-flex; align-items:center; gap:4px; font-size:.82rem; }
.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); }
.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; }
/* ── Controls Row Clickable ─────────────────────────────── */
.data-table tbody tr.clickable-row { cursor: pointer; }
/* ── Loading/Empty ──────────────────────────────────────── */
.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: 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;
}
/* ── Loading / Empty ────────────────────────────────────── */
.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; }
@keyframes rpt-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:.5; }
.empty-state-box h4 { color:var(--gray-600); margin:0 0 4px; }
.empty-state-box p { margin:0; }
/* ── Print ──────────────────────────────────────────────── */
@media print {
.sidebar, .content-header, .tab-nav, .btn { display: none !important; }
.main-content { margin: 0 !important; padding: 0 !important; }
.report-container { box-shadow: none; border: 1px solid #ddd; }
body { background: white; }
#topnav, .crumb, .page-head .btn-primary, .tab-nav, .btn { display:none !important; }
.wrap { max-width:100% !important; padding:0 !important; }
.report-container { box-shadow:none; border:1px solid #ddd; }
body { background:#fff; }
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.tab-nav button { padding: 10px 12px; font-size: 0.8rem; }
.iso-mapping-row { flex-direction: column; align-items: flex-start; }
.iso-title { min-width: auto; }
.report-summary-grid { grid-template-columns: 1fr 1fr; }
@media (max-width:768px) {
.tab-nav button { padding:10px 12px; font-size:.8rem; }
.iso-mapping-row { flex-direction:column; align-items:flex-start; }
.iso-title { min-width:auto; }
.report-summary-grid { grid-template-columns:1fr 1fr; }
}
</style>
<!-- PWA:start -->
@@ -358,18 +141,23 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="audit.title">Audit &amp; Report</h1>
<div class="content-header-actions">
<button class="btn btn-primary" onclick="generateReport()">Genera Report</button>
<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>Audit e Report</span>
</nav>
<div class="page-head">
<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>
</header>
<div class="content-body">
<section class="card card-pad">
<!-- Tab Navigation -->
<div class="tab-nav">
<button class="active" onclick="switchTab('report', this)">Report Compliance</button>
@@ -439,30 +227,24 @@
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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>
<!-- 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>
// ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
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) {} }
// ── Labels ──────────────────────────────────────────────
const controlStatusLabels = {
@@ -1017,11 +799,8 @@
}
let html = `
<div class="card">
<div class="card-header">
<h3>Mapping NIS2 - ISO 27001:2022</h3>
</div>
<div class="card-body">
<div>
<h3 style="margin:0 0 4px;font-size:1.05rem;">Mapping NIS2 - ISO 27001:2022</h3>
<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>`;
@@ -1041,7 +820,7 @@
</div>`;
});
html += '</div></div>';
html += '</div>';
container.innerHTML = html;
}
+78 -62
View File
@@ -3,51 +3,60 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Scadenziario - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Scadenziario · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style>
.rev-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:16px; font-size:.92rem; line-height:1.6; }
.rev-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 18px; }
.rev-stats { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:18px; }
.rev-stat { flex:1 1 150px; min-width:130px; background:#fff; border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; padding:12px 14px; }
.rev-stat .n { font-size:1.5rem; font-weight:800; line-height:1; }
.rev-stat .l { font-size:.78rem; color:var(--gray-500,#6b7280); margin-top:4px; }
/* V3 — specifico pagina Scadenziario (non coperto da v3.css) */
.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-note { font-size:12.5px; color:#92400e; background:#FBF1E2; border:1px solid #fde68a; border-radius:10px; padding:10px 12px; margin:0 0 18px; }
.rev-stats { display:flex; flex-wrap:wrap; gap:12px; margin-bottom:18px; }
.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 .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-due .n { color:#b45309; }
.rev-stat.s-ok .n { color:#166534; }
.rev-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.rev-toolbar select { padding:8px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.88rem; }
.rev-toolbar { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.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-table { width:100%; border-collapse:collapse; font-size:.88rem; }
.rev-table th, .rev-table td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
.rev-table th { font-size:.74rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
.rev-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; }
.rev-badge.type { background:#f1f5f9; color:#475569; }
.gap-pill { display:inline-block; font-size:.72rem; font-weight:800; padding:2px 9px; border-radius:20px; }
.gap-pill.ok { background:#dcfce7; color:#166534; }
.gap-pill.due { background:#fef3c7; color:#92400e; }
.gap-pill.bad { background:#fee2e2; color:#991b1b; }
.rev-table { width:100%; border-collapse:collapse; font-size:13px; }
.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:10.5px; text-transform:uppercase; letter-spacing:.04em; color:#7a8aa0; }
.rev-badge { display:inline-block; font-size:10.5px; font-weight:700; padding:2px 8px; border-radius:6px; }
.rev-badge.type { background:#EEF2F7; color:#475569; }
.gap-pill { display:inline-block; font-size:11px; font-weight:800; padding:2px 9px; border-radius:20px; }
.gap-pill.ok { background:#E6F4EC; color:#166534; }
.gap-pill.due { background:#FBF1E2; color:#92400e; }
.gap-pill.bad { background:#FBE9E9; color:#991b1b; }
.rev-actions-cell { display:flex; gap:6px; flex-wrap:wrap; }
.rev-actions-cell .btn, .rev-toolbar .btn { padding:4px 9px; font-size:.76rem; }
.rev-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); }
/* Modale */
.rm-overlay { display:none; position:fixed; inset:0; background:rgba(15,23,42,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 16px; }
.rev-empty { text-align:center; padding:34px 16px; color:var(--muted); }
/* Modale (page-owned) */
.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-dialog { background:#fff; border-radius:12px; width:100%; max-width:560px; box-shadow:0 20px 50px rgba(0,0,0,.25); }
.rm-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
.rm-head h3 { margin:0; font-size:1.05rem; }
.rm-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(--line); }
.rm-head h3 { margin:0; font-size:16px; }
.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 label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; }
.rm-field input[type=text], .rm-field input[type=number], .rm-field input[type=date], .rm-field select, .rm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; }
.rm-field 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(--line); border-radius:8px; font:inherit; font-size:14px; }
.rm-field textarea { min-height:64px; resize:vertical; }
.rm-row { display:flex; gap:12px; }
.rm-row > .rm-field { flex:1 1 0; }
.rm-err { color:#b91c1c; font-size:.82rem; min-height:18px; }
.rm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); }
.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(--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>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -64,17 +73,24 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="rev.title">Scadenziario</h2>
<div class="content-header-actions">
<span class="savehint" id="rev-hint" role="status" aria-live="polite"></span>
</div>
</header>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js -->
<div id="topnav"></div>
<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>Monitoraggio</span>
<i data-lucide="chevron-right" class="ic"></i><span>Scadenziario</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="rev.title">Scadenziario</h1>
<p class="sub">Revisioni periodiche di ruoli, competenze, inventario, procedure, rischi, fornitori e misure</p>
</div>
<span class="savehint" id="rev-hint" role="status" aria-live="polite" style="font-size:12.5px;color:var(--muted)"></span>
</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.
@@ -91,10 +107,10 @@
</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>
<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:.82rem;">Stato:</label>
<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>
@@ -104,9 +120,8 @@
</div>
<div id="rev-list" aria-live="polite"></div>
</div>
</section>
</main>
</div>
<!-- Modale scadenza (crea/modifica) -->
<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-err" id="rev-err" role="alert"></div>
</div>
<div class="rm-foot"><button class="btn btn-outline" type="button" onclick="revClose()">Annulla</button><button class="btn btn-primary" type="button" id="rev-save" onclick="revSave()">Salva</button></div>
<div 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>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
<!-- Scripts V3 (ordine ESATTO come dashboard.html) -->
<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>
'use strict';
/*
@@ -180,6 +196,7 @@
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar();
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(); });
const ov = el('rev-modal');
@@ -195,7 +212,6 @@
REV.roles = (rolesData && rolesData.roles) || [];
revFillOwnerSelect();
} catch (e) {
// l'organigramma può non essere ancora popolato: non bloccare la pagina
REV.roles = [];
}
}
@@ -210,7 +226,7 @@
}
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 {
const data = await api.revList();
REV.items = (data && data.items) || [];
@@ -250,16 +266,16 @@
const freq = (i.frequency_months != null && i.frequency_months !== '') ? (esc(i.frequency_months) + ' mesi') : '';
return '<tr>'
+ '<td><strong>' + esc(i.title) + '</strong>'
+ (i.notes ? '<div class="gap-role-sub" style="font-size:.8rem;color:var(--gray-500,#6b7280);margin-top:3px;">' + esc(i.notes) + '</div>' : '') + '</td>'
+ (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>' + owner + '</td>'
+ '<td>' + fmtDate(i.last_reviewed_at) + '</td>'
+ '<td>' + fmtDate(i.next_review_date) + (freq ? '<div style="font-size:.78rem;color:var(--gray-500,#6b7280);">ogni ' + freq + '</div>' : '') + '</td>'
+ '<td>' + 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><div class="rev-actions-cell">'
+ '<button class="btn btn-outline" onclick="revComplete(' + i.id + ')">Segna revisionato</button>'
+ '<button class="btn btn-outline" onclick="revOpen(' + i.id + ')">Modifica</button>'
+ '<button class="btn btn-outline" onclick="revDelete(' + i.id + ')">Elimina</button>'
+ '<button class="btn-ghost" onclick="revComplete(' + i.id + ')">Segna revisionato</button>'
+ '<button class="btn-ghost" onclick="revOpen(' + i.id + ')">Modifica</button>'
+ '<button class="btn-ghost" onclick="revDelete(' + i.id + ')">Elimina</button>'
+ '</div></td>'
+ '</tr>';
}).join('');
+825 -387
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>
+159 -119
View File
@@ -3,22 +3,65 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Impostazioni - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css
cosi' style.css (design system esistente) resta autorevole per i widget renderizzati
via JS (tabelle/modali/form) -> parita' funzionale assoluta. BI veste la chrome. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Impostazioni · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Bootstrap Italia / NO CDN). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style>
/* ── Helper generici non (ancora) in v3.css, usati dal markup reso via JS.
Self-host, nessun CDN. v3.css governa chrome + shell + widget condivisi. ── */
:root { --success: var(--secondary); }
.text-muted { color: var(--gray-500); }
.mb-24 { margin-bottom: 24px; }
.card-header {
padding: 16px 24px;
border-bottom: 1px solid var(--gray-100);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-header h3 { font-size: 1rem; font-weight: 700; color: var(--gray-900); }
.card-body { padding: 24px; }
.form-control {
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); outline: none;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.form-control:focus { border-color: var(--navy-nav); box-shadow: 0 0 0 3px rgba(31,95,168,.15); }
.form-control::placeholder { color: var(--gray-400); }
/* Tabelle rese via JS (membri, API key, webhook, delivery, audit) */
.table-container { overflow-x: auto; border-radius: var(--border-radius-lg); }
.table-container table { width: 100%; border-collapse: collapse; font-size: .875rem; }
.table-container thead { background: var(--gray-50); }
.table-container thead th {
padding: 12px 16px; font-weight: 600; color: var(--gray-600); text-align: left;
font-size: .75rem; text-transform: uppercase; letter-spacing: .04em;
border-bottom: 2px solid var(--gray-200); white-space: nowrap;
}
.table-container tbody td { padding: 12px 16px; border-bottom: 1px solid var(--gray-100); color: var(--gray-700); }
.table-container tbody tr { transition: background var(--transition-fast); }
.table-container tbody tr:hover { background: var(--gray-50); }
.table-container tbody tr:last-child td { border-bottom: none; }
/* Empty state reso via JS */
.empty-state { text-align: center; padding: 48px 24px; color: var(--gray-400); }
.empty-state svg { width: 64px; height: 64px; margin-bottom: 16px; opacity: .4; }
.empty-state h4 { font-size: 1rem; color: var(--gray-600); margin-bottom: 8px; }
.empty-state p { font-size: .875rem; max-width: 320px; margin: 0 auto; }
/* ── Stili specifici della pagina Impostazioni (non coperti da v3.css) ── */
.settings-tabs {
display: flex;
border-bottom: 2px solid var(--gray-200);
margin-bottom: 24px;
margin-bottom: 0;
gap: 0;
overflow-x: auto;
}
.settings-tab {
padding: 12px 24px;
padding: 12px 20px;
font-size: 0.875rem;
font-weight: 600;
color: var(--gray-500);
@@ -31,22 +74,15 @@
white-space: nowrap;
font-family: inherit;
}
.settings-tab:hover {
color: var(--gray-700);
}
.settings-tab:hover { color: var(--gray-700); }
.settings-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.settings-section {
margin-bottom: 32px;
color: var(--navy-nav);
border-bottom-color: var(--navy-nav);
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.settings-section { margin-bottom: 32px; }
.settings-section:last-child { margin-bottom: 0; }
.settings-section-title {
font-size: 1rem;
font-weight: 700;
@@ -101,11 +137,6 @@
.plan-badge.free { background: var(--gray-100); color: var(--gray-600); }
.plan-badge.professional { background: var(--primary-bg); color: var(--primary); }
.plan-badge.enterprise { background: #f3e8ff; color: #7c3aed; }
.password-section {
border-top: 1px solid var(--gray-200);
padding-top: 24px;
margin-top: 24px;
}
.strength-bar {
display: flex;
gap: 4px;
@@ -147,6 +178,7 @@
border-radius: var(--border-radius);
margin-bottom: 16px;
}
.security-item:last-child { margin-bottom: 0; }
.security-item-header {
display: flex;
align-items: center;
@@ -186,7 +218,8 @@
align-items: center;
justify-content: center;
}
.btn-icon-action:hover {
.btn-icon-action:hover,
.btn-icon-action.btn-danger-hover:hover {
border-color: var(--danger);
color: var(--danger);
background: var(--danger-bg);
@@ -195,6 +228,9 @@
width: 16px;
height: 16px;
}
/* Bottoni reso via JS che combinano btn + variante: assicura l'allineamento icona */
.btn svg { width: 18px; height: 18px; }
.required { color: var(--danger); margin-left: 2px; }
</style>
<!-- PWA:start -->
<link rel="manifest" href="/manifest.webmanifest">
@@ -211,19 +247,24 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
<div id="topnav"></div>
<main class="main-content">
<header class="content-header">
<h1 class="h2" data-i18n="settings.title">Impostazioni</h1>
<div class="content-header-actions">
<span class="text-muted" id="header-org-name"></span>
<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="settings.title">Impostazioni</span>
</nav>
<div class="page-head">
<div>
<h1 data-i18n="settings.title">Impostazioni</h1>
<p class="sub">Profilo, organizzazione, membri, sicurezza e integrazioni</p>
</div>
<div class="text-muted" id="header-org-name"></div>
</div>
</header>
<div class="content-body">
<!-- Tab Navigation -->
<!-- Tab Navigation (gli INDICI di tabMap dipendono dall'ordine: NON modificarlo) -->
<section class="card card-pad" style="padding-bottom:0;">
<div class="settings-tabs">
<button class="settings-tab active" onclick="switchTab('org')">Organizzazione</button>
<button class="settings-tab" onclick="switchTab('profile')">Profilo</button>
@@ -235,11 +276,12 @@
<button class="settings-tab" onclick="switchTab('webhooks')">Webhook</button>
<button class="settings-tab" id="tab-btn-branding" onclick="switchTab('branding')" style="display:none;">Branding</button>
</div>
</section>
<div class="stack" style="margin-top:18px;">
<!-- ══════════════ TAB: Organizzazione ══════════════ -->
<div class="tab-panel active" id="tab-org">
<div class="card">
<div class="card-body">
<section class="card card-pad">
<div class="settings-section">
<h3 class="settings-section-title">Classificazione NIS2</h3>
<p class="settings-section-desc">La classificazione viene calcolata automaticamente in base a settore, numero di dipendenti e fatturato.</p>
@@ -261,7 +303,7 @@
<p class="settings-section-desc">Informazioni generali dell'organizzazione soggetta a compliance NIS2.</p>
<form id="org-form" onsubmit="saveOrganization(event)">
<div class="form-row">
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="form-group">
<label for="org-name" class="form-label">Nome Organizzazione <span class="required">*</span></label>
<input type="text" class="form-input" id="org-name" required>
@@ -291,7 +333,7 @@
</div>
</div>
<div class="form-row">
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="form-group">
<label for="org-vat" class="form-label">Partita IVA</label>
<input type="text" class="form-input" id="org-vat" placeholder="IT12345678901">
@@ -302,7 +344,7 @@
</div>
</div>
<div class="form-row">
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="form-group">
<label for="org-employees" class="form-label">Numero Dipendenti</label>
<input type="number" class="form-input" id="org-employees" min="0" placeholder="0">
@@ -313,7 +355,7 @@
</div>
</div>
<div class="form-row">
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="form-group">
<label for="org-country" class="form-label">Paese</label>
<input type="text" class="form-input" id="org-country" placeholder="IT">
@@ -329,7 +371,7 @@
<input type="text" class="form-input" id="org-address">
</div>
<div class="form-row">
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="form-group">
<label for="org-website" class="form-label">Sito Web</label>
<input type="url" class="form-input" id="org-website" placeholder="https://...">
@@ -345,19 +387,18 @@
<input type="tel" class="form-input" id="org-phone" style="max-width:320px;">
</div>
<button type="submit" class="btn btn-primary" id="btn-save-org">
<button type="submit" class="btn-primary" id="btn-save-org">
<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"/></svg>
Salva Modifiche
</button>
</form>
</div>
</div>
</div>
</section>
</div>
<!-- ══════════════ TAB: Profilo ══════════════ -->
<div class="tab-panel" id="tab-profile">
<div class="card mb-24">
<section class="card mb-24">
<div class="card-header">
<h3>Informazioni Personali</h3>
</div>
@@ -372,7 +413,7 @@
<span class="form-help">L'email non puo' essere modificata.</span>
</div>
<div class="form-row">
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="form-group">
<label for="profile-name" class="form-label">Nome Completo <span class="required">*</span></label>
<input type="text" class="form-input" id="profile-name" required>
@@ -391,16 +432,16 @@
</select>
</div>
<button type="submit" class="btn btn-primary" id="btn-save-profile">
<button type="submit" class="btn-primary" id="btn-save-profile">
<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"/></svg>
Aggiorna Profilo
</button>
</form>
</div>
</div>
</div>
</section>
<div class="card">
<section class="card">
<div class="card-header">
<h3>Cambia Password</h3>
</div>
@@ -430,23 +471,24 @@
<input type="password" class="form-input" id="pw-confirm" required autocomplete="new-password">
</div>
<button type="submit" class="btn btn-warning" id="btn-change-pw">
<button type="submit" class="btn-warning" id="btn-change-pw">
<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>
Cambia Password
</button>
</form>
</div>
</div>
</section>
</div>
<!-- ══════════════ TAB: Membri ══════════════ -->
<!-- ══════════════ TAB: Preferenze (Fase 4 / G12) ══════════════ -->
<div class="tab-panel" id="tab-preferences">
<div class="card">
<section class="card">
<div class="card-header">
<div>
<h3>Preferenze utente</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Personalizzazione interfaccia e notifiche.</p>
</div>
</div>
<div class="card-body">
<form id="preferences-form" style="max-width:560px;">
<div class="form-group">
@@ -497,19 +539,20 @@
</div>
<div class="form-group" style="margin-top:24px;">
<button type="submit" class="btn btn-primary" id="pref-save-btn">Salva preferenze</button>
<button type="submit" class="btn-primary" id="pref-save-btn">Salva preferenze</button>
<span id="pref-saved-msg" style="margin-left:12px; color:var(--success); display:none; font-size:.85rem;">✓ Salvato</span>
</div>
</form>
</div>
</div>
</section>
</div>
<!-- ══════════════ TAB: Membri ══════════════ -->
<div class="tab-panel" id="tab-members">
<div class="card">
<section class="card">
<div class="card-header">
<h3>Membri dell'Organizzazione</h3>
<button class="btn btn-primary btn-sm" onclick="showInviteModal()">
<button class="btn-primary btn-sm" onclick="showInviteModal()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><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>
Invita Membro
</button>
@@ -519,27 +562,27 @@
<div class="spinner" style="margin:40px auto;"></div>
</div>
</div>
</div>
</section>
</div>
<!-- ══════════════ TAB: Permessi documentali ══════════════ -->
<div class="tab-panel" id="tab-docperms">
<div class="card">
<section class="card">
<div class="card-header"><h3>Permessi documentali (ciclo di vita SGSI)</h3></div>
<div class="card-body">
<p class="text-muted" style="margin-top:0;">Definisci quali <strong>ruoli</strong> possono compiere ogni azione del ciclo di vita dei documenti del Modello SGSI (ISO/IEC 27001 cl. 7.5). La configurazione vale per questa organizzazione; il super amministratore ha sempre tutti i permessi.</p>
<div id="docperms-matrix"><div class="spinner" style="margin:30px auto;"></div></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:14px;">
<span id="docperms-hint" class="text-muted" style="font-size:.85rem;"></span>
<button class="btn btn-primary" id="docperms-save" onclick="saveDocPerms()">Salva permessi</button>
</div>
<button class="btn-primary" id="docperms-save" onclick="saveDocPerms()">Salva permessi</button>
</div>
</div>
</section>
</div>
<!-- ══════════════ TAB: Sicurezza ══════════════ -->
<div class="tab-panel" id="tab-security">
<div class="card mb-24">
<section class="card mb-24">
<div class="card-header">
<h3>Sicurezza Account</h3>
</div>
@@ -560,24 +603,24 @@
<p class="security-item-desc">L'accesso alle API avviene tramite token JWT (JSON Web Token) con scadenza automatica e meccanismo di refresh.</p>
</div>
</div>
</div>
</section>
<div class="card mb-24" id="card-sessions">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<section class="card mb-24" id="card-sessions">
<div class="card-header">
<div>
<h3>Sessioni Attive</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Dispositivi attualmente loggati al tuo account. Puoi disconnetterli singolarmente o tutti tranne questo.</p>
</div>
<button class="btn btn-outline btn-danger" onclick="revokeAllOtherSessions()" id="revoke-all-btn" style="display:none;">Disconnetti gli altri</button>
<button class="btn-outline btn-danger" onclick="revokeAllOtherSessions()" id="revoke-all-btn" style="display:none;">Disconnetti gli altri</button>
</div>
<div class="card-body" style="padding:0;">
<div id="sessions-container">
<div class="spinner" style="margin:40px auto;"></div>
</div>
</div>
</div>
</section>
<div class="card">
<section class="card">
<div class="card-header">
<h3>Registro Attivita' Recenti</h3>
</div>
@@ -586,20 +629,20 @@
<div class="spinner" style="margin:40px auto;"></div>
</div>
</div>
</div>
</section>
</div>
<!-- ══════════════ TAB: API Keys ══════════════ -->
<div class="tab-panel" id="tab-apikeys">
<div class="card mb-24">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<section class="card mb-24">
<div class="card-header">
<div>
<h3>API Keys</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">
Chiavi per accesso esterno alle API NIS2 Agile (SIEM, GRC, dashboard esterne).
</p>
</div>
<button class="btn btn-primary" onclick="showCreateApiKeyModal()">
<button class="btn-primary" onclick="showCreateApiKeyModal()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><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>
Nuova API Key
</button>
@@ -607,26 +650,26 @@
<div class="card-body" style="padding:0;">
<div id="apikeys-container"><div class="spinner" style="margin:40px auto;"></div></div>
</div>
</div>
<div class="card">
</section>
<section class="card">
<div class="card-header"><h3>Scope Disponibili</h3></div>
<div class="card-body">
<div id="available-scopes-container"></div>
</div>
</div>
</section>
</div>
<!-- ══════════════ TAB: Webhook ══════════════ -->
<div class="tab-panel" id="tab-webhooks">
<div class="card mb-24">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<section class="card mb-24">
<div class="card-header">
<div>
<h3>Webhook Subscriptions</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">
Notifiche push verso sistemi esterni (SIEM, 231 Agile, SustainAI) su eventi NIS2.
</p>
</div>
<button class="btn btn-primary" onclick="showCreateWebhookModal()">
<button class="btn-primary" onclick="showCreateWebhookModal()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><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>
Nuovo Webhook
</button>
@@ -634,25 +677,27 @@
<div class="card-body" style="padding:0;">
<div id="webhooks-container"><div class="spinner" style="margin:40px auto;"></div></div>
</div>
</div>
<div class="card">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
</section>
<section class="card">
<div class="card-header">
<h3>Delivery Log</h3>
<button class="btn btn-sm btn-secondary" onclick="loadDeliveries()">Aggiorna</button>
<button class="btn-sm btn-secondary" onclick="loadDeliveries()">Aggiorna</button>
</div>
<div class="card-body" style="padding:0;">
<div id="deliveries-container"><div class="empty-state" style="padding:32px;"><p>Seleziona un webhook per vedere i delivery.</p></div></div>
</div>
</div>
</section>
</div>
<!-- ══════════════ TAB: Branding (Fase 5 / G16) ══════════════ -->
<div class="tab-panel" id="tab-branding">
<div class="card">
<section class="card">
<div class="card-header">
<div>
<h3>Branding white-label</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Personalizza colori, logo e nome prodotto per il tuo studio. Visibile a tutti i tuoi clienti loggati.</p>
</div>
</div>
<div class="card-body">
<form id="branding-form" style="max-width:560px;">
<div class="form-group">
@@ -676,40 +721,35 @@
</div>
<div class="form-group" style="margin-top:24px;">
<button type="submit" class="btn btn-primary" id="brand-save-btn">Salva branding</button>
<button type="submit" class="btn-primary" id="brand-save-btn">Salva branding</button>
<span id="brand-saved-msg" style="margin-left:12px; color:var(--success); display:none; font-size:.85rem;">✓ Salvato — ricarica per vedere</span>
</div>
</form>
</div>
</div>
</section>
</div>
</div>
</main>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
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=20260620c"></script>
<!-- Scripts V3: api.js (client REST, prima) → common.js (helper+FAB reali) →
Lucide self-host → topnav-v3.js (chrome, ridefinisce loadSidebar) →
i18n → help → init pagina (inline, invariato). -->
<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>
// ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated');
// ── Init ─────────────────────────────────────────────────
loadSidebar();
I18n.init();
HelpSystem.init();
// ── Init chrome + i18n + help ────────────────────────────
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) {} }
let currentOrg = null;
let currentUser = null;
@@ -898,7 +938,7 @@
: '';
const actionBtn = isCurrent
? ''
: '<button class="btn btn-sm btn-outline btn-danger" onclick="revokeSession(\'' + s.id + '\')">Disconnetti</button>';
: '<button class="btn-sm btn-outline btn-danger" onclick="revokeSession(\'' + s.id + '\')">Disconnetti</button>';
return '<div style="display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100);">'
+ '<div>'
+ '<div style="font-weight:600;">' + escapeHtml(s.device_label) + badge + '</div>'
@@ -1313,7 +1353,7 @@
<td><span class="member-role-badge ${role}">${escapeHtml(roleLabel)}</span></td>
<td>${formatDate(m.joined_at)}</td>
<td>
<button class="btn-icon-action" onclick="removeMember(${m.id}, '${escapeHtml(m.full_name || m.email)}')" title="Rimuovi membro">
<button class="btn-icon-action btn-danger-hover" onclick="removeMember(${m.id}, '${escapeHtml(m.full_name || m.email)}')" title="Rimuovi membro">
<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>
@@ -1343,8 +1383,8 @@
</div>
`, {
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-primary" onclick="doInviteMember()">Invita</button>
<button class="btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn-primary" onclick="doInviteMember()">Invita</button>
`
});
}
@@ -1387,8 +1427,8 @@
</p>
`, {
footer: `
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn btn-danger" onclick="doRemoveMember(${userId})">Rimuovi</button>
<button class="btn-secondary" onclick="closeModal()">Annulla</button>
<button class="btn-danger" onclick="doRemoveMember(${userId})">Rimuovi</button>
`
});
}
@@ -1480,7 +1520,7 @@
<label for="apikey-expires" class="form-label">Scadenza (opzionale)</label>
<input type="date" id="apikey-expires" class="form-control">
</div>
`, '<button class="btn btn-primary" onclick="createApiKey()">Crea API Key</button>');
`, '<button class="btn-primary" onclick="createApiKey()">Crea API Key</button>');
}
async function createApiKey() {
@@ -1501,7 +1541,7 @@
${escapeHtml(result.data.key)}
</div>
<p style="font-size:0.8rem; color:var(--gray-500); margin-top:8px;">Usa come header: <code>X-API-Key: ${escapeHtml(result.data.key)}</code></p>
`, '<button class="btn btn-primary" onclick="closeModal(); loadApiKeys();">Ho salvato la chiave</button>');
`, '<button class="btn-primary" onclick="closeModal(); loadApiKeys();">Ho salvato la chiave</button>');
} else { showNotification(result.message || 'Errore.', 'error'); }
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
@@ -1590,7 +1630,7 @@
<label class="form-label">Eventi da ascoltare *</label>
<div style="max-height:200px; overflow-y:auto; padding:8px; border:1px solid var(--gray-200); border-radius:var(--border-radius); margin-top:8px;">${evtCheckboxes}</div>
</div>
`, '<button class="btn btn-primary" onclick="createWebhook()">Crea Webhook</button>');
`, '<button class="btn-primary" onclick="createWebhook()">Crea Webhook</button>');
}
async function createWebhook() {
@@ -1612,7 +1652,7 @@
${escapeHtml(result.data.secret)}
</div>
<p style="font-size:0.75rem; color:var(--gray-500); margin-top:8px;">Firma: <code>sha256=HMAC_SHA256(body, secret)</code></p>
`, '<button class="btn btn-primary" onclick="closeModal(); loadWebhooks();">Ho salvato il secret</button>');
`, '<button class="btn-primary" onclick="closeModal(); loadWebhooks();">Ho salvato il secret</button>');
} else showNotification(result.message || 'Errore.', 'error');
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
+40 -18
View File
@@ -3,11 +3,22 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Attività stakeholder - NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Attività stakeholder · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg CDN / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<style>
.sa-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
/* ── 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-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; }
@@ -72,12 +83,21 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="sact.title">Attività stakeholder</h2>
</header>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js -->
<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><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="sa-intro">
<strong>Attività verso gli stakeholder.</strong>
@@ -107,7 +127,6 @@
</section>
</div>
</main>
</div>
<!-- Modale questionario tipo -->
<div class="sm-overlay" id="tpl-modal" role="dialog" aria-modal="true" aria-labelledby="tpl-modal-title">
@@ -194,12 +213,14 @@
</div>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
<!-- 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>
'use strict';
/*
@@ -217,8 +238,9 @@
document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar();
if (typeof loadSidebar === 'function') loadSidebar(); // = loadTopnavV3()
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(); } });
['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();
+44 -24
View File
@@ -3,12 +3,22 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Stakeholder - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260618">
<title>Stakeholder · NIS2 Agile</title>
<!-- UI V3: Inter self-host + design system v3.css (NO Google Fonts / NO unpkg CDN / NO Bootstrap Italia). -->
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
<link rel="stylesheet" href="/css/v3.css?v=20260627">
<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-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.stk-toolbar .grow { flex:1 1 auto; }
@@ -84,18 +94,26 @@
<!-- PWA:end -->
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2 data-i18n="stk.title">Stakeholder</h2>
<div class="content-header-actions">
<button class="btn btn-outline btn-sm" onclick="stkTypeOpen()">+ Aggiungi tipo</button>
<button class="btn btn-primary btn-sm" onclick="stkOpen()">+ Aggiungi stakeholder</button>
</div>
</header>
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js -->
<div id="topnav"></div>
<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">
<strong>Mappatura e gestione degli stakeholder.</strong>
Registra gli stakeholder interni ed esterni scegliendo il tipo dall'elenco (puoi aggiungerne di nuovi),
@@ -130,7 +148,6 @@
<div id="stk-groups" aria-live="polite"></div>
</div>
</main>
</div>
<!-- Modale stakeholder (crea/modifica) -->
<div class="sm-overlay" id="stk-modal" role="dialog" aria-modal="true" aria-labelledby="stk-modal-title">
@@ -191,12 +208,14 @@
</div>
</div>
<script src="/js/api.js?v=20260631"></script>
<script src="/js/common.js?v=20260631"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></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>
<!-- 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>
'use strict';
/*
@@ -223,8 +242,9 @@
document.addEventListener('DOMContentLoaded', async function () {
if (typeof checkAuth === 'function' && !checkAuth()) return;
if (window.I18n && I18n.init) I18n.init('it');
if (typeof loadSidebar === 'function') loadSidebar();
if (typeof loadSidebar === 'function') loadSidebar(); // = loadTopnavV3()
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(); } });
['stk-modal','stk-type-modal'].forEach(function (mid) {

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