[UI] V2 completezza Bootstrap Italia: 11 pagine app -bi mancanti + companies + help.js + KB doc
Completa la copertura V2 al 100% delle pagine app autenticate (pre-swap).
Additivo: nessun file live HTML promosso ancora (lo swap e separato).
- 9 pagine app -bi: isms, acn-gap, normative, whistleblowing, kb, cross-analysis,
service-continuity (doc standalone), guida, companies
- admin/{index,organizations,users}-bi.html (path corretti, common-bi.js)
- help.js: +89 righe help contestuale pagine V2 (vincolo fonti certe)
- docs/kb/V2_UI_NAVIGAZIONE.md: doc UI/navigazione V2 per la KB AI (ARIA)
- Verifica: tutte servite 200, 0 CDN/FontAwesome, sprite validi, parita ID
Prodotto via Workflow a trittici (run wf_c4962fbe-93f) + fix manuale companies/sprite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4d676f2e21
commit
36967f9452
@@ -0,0 +1,179 @@
|
||||
FONTE: Documentazione interfaccia prodotto NIS2 Agile (interfaccia V2 "Bootstrap Italia").
|
||||
AUTORITA: Team prodotto NIS2 Agile — descrizione UI/navigazione (NON contenuto normativo).
|
||||
SCOPO: guida operativa per l'assistente AI ARIA, così da poter indirizzare l'utente nella nuova interfaccia V2 (pagine "-bi"). Questo documento descrive SOLO la struttura e la navigazione dell'app; non contiene affermazioni normative.
|
||||
|
||||
# NIS2 Agile — Interfaccia V2 (Bootstrap Italia): struttura e navigazione
|
||||
|
||||
## Cos'è la V2 "Bootstrap Italia"
|
||||
|
||||
La V2 è la nuova interfaccia di NIS2 Agile costruita con il design system **Bootstrap Italia**. Tecnicamente è una variante delle pagine esistenti: ogni pagina convertita ha un file con suffisso `-bi.html` (es. `dashboard-bi.html`, `risks-bi.html`). La chrome (sidebar e barra utente) è generata da `public/js/common-bi.js`, che ridefinisce solo il rendering della sidebar riusando tutti gli helper e l'albero di navigazione della V1: gli ID DOM, le voci di menu e i collegamenti restano identici alla V1, cambia solo la resa grafica (markup e stile Bootstrap Italia, accessibilità con `<nav aria-label>`, link attivo con `aria-current`).
|
||||
|
||||
Architettura: l'app è **multi-pagina** (non SPA). Ogni modulo è una pagina HTML a sé. La sidebar a sinistra è il punto di navigazione principale ed è presente in tutte le pagine applicative (quelle con il riquadro `<aside class="sidebar" id="sidebar">`). Le pagine pubbliche (login, registrazione, onboarding, landing, recupero password) NON hanno la sidebar.
|
||||
|
||||
### Pagine già convertite a Bootstrap Italia (mappa `BI_PAGES`)
|
||||
|
||||
Per queste pagine, quando si naviga in contesto V2, i link puntano automaticamente alla variante `-bi`:
|
||||
|
||||
- `login.html` → `login-bi.html`
|
||||
- `dashboard.html` → `dashboard-bi.html`
|
||||
- `assessment.html` → `assessment-bi.html`
|
||||
- `risks.html` → `risks-bi.html`
|
||||
- `incidents.html` → `incidents-bi.html`
|
||||
- `policies.html` → `policies-bi.html`
|
||||
- `supply-chain.html` → `supply-chain-bi.html`
|
||||
- `training.html` → `training-bi.html`
|
||||
- `assets.html` → `assets-bi.html`
|
||||
- `reports.html` → `reports-bi.html`
|
||||
- `settings.html` → `settings-bi.html`
|
||||
|
||||
Le pagine NON ancora convertite (es. acn-gap, isms, whistleblowing, normative, cross-analysis, kb, guida, service-continuity) restano servite dalla versione originale: i link funzionano ugualmente (parità funzionale, nessun link rotto), ma la grafica può essere ancora quella V1 finché la conversione `-bi` non è completata. L'evidenziazione della voce attiva nella sidebar funziona sia per `x.html` sia per `x-bi.html` (il suffisso `-bi` viene normalizzato).
|
||||
|
||||
---
|
||||
|
||||
## Struttura della sidebar (menu di navigazione)
|
||||
|
||||
La sidebar è organizzata in **4 sezioni** con etichette. In alto c'è il brand "NIS2 Agile — Compliance Platform"; subito sotto, per gli utenti consulente, può comparire un selettore di organizzazione (org-switcher). In basso ci sono: toggle lingua **IT / EN**, il box utente (avatar, nome, ruolo), il pulsante **Esci** (logout) e la versione dell'applicazione.
|
||||
|
||||
### Sezione 1 — Principale
|
||||
- **Dashboard** (`dashboard.html`) — pagina iniziale dopo il login: panoramica generale dello stato di conformità.
|
||||
- **Compliance Journey** (`workflow.html`) — percorso guidato/flusso di lavoro per la conformità.
|
||||
- **Gap Analysis** (`assessment.html`) — questionario di valutazione e analisi degli scostamenti.
|
||||
- **Gap Analysis ACN** (`acn-gap.html`) — valutazione di secondo livello sulle misure/requisiti ACN.
|
||||
- **Modello SGSI (ISO 27001)** (`isms.html`) — modulo Sistema di Gestione della Sicurezza delle Informazioni.
|
||||
|
||||
### Sezione 2 — Gestione
|
||||
- **Rischi** (`risks.html`) — registro dei rischi e relativa gestione.
|
||||
- **Incidenti** (`incidents.html`) — gestione degli incidenti.
|
||||
- **Policy** (`policies.html`) — gestione delle policy/documenti.
|
||||
- **Supply Chain** (`supply-chain.html`) — gestione fornitori e catena di fornitura.
|
||||
- **Segnalazioni** (`whistleblowing.html`) — segnalazioni/whistleblowing.
|
||||
- **Normative** (`normative.html`) — aggiornamenti normativi.
|
||||
- **AI Cross-Analysis** (`cross-analysis.html`) — analisi incrociata assistita da AI.
|
||||
- **Knowledge Base** (`kb.html`) — base di conoscenza documentale.
|
||||
|
||||
### Sezione 3 — Operativo
|
||||
- **Formazione** (`training.html`) — corsi e formazione.
|
||||
- **Asset** (`assets.html`) — inventario degli asset.
|
||||
- **Audit & Report** (`reports.html`) — audit e reportistica.
|
||||
|
||||
### Sezione 4 — Sistema
|
||||
- **Guida all'uso** (`guida.html`) — manuale/guida d'uso.
|
||||
- **Impostazioni** (`settings.html`) — impostazioni organizzazione/profilo/membri.
|
||||
- **Architettura** (`architecture.html`) — pagina informativa sull'architettura del sistema.
|
||||
- **Simulazione Demo** (`simulate.html`) — ambiente dimostrativo.
|
||||
- **Integrazioni Esterne** (`integrazioniext.html`) — integrazioni con sistemi esterni.
|
||||
|
||||
> Nota: la pagina **Continuità di Servizio** (`service-continuity.html`) è disponibile nell'app ma NON è una voce della sidebar principale: si raggiunge tramite link/contesto da altre pagine, non dal menu laterale.
|
||||
|
||||
---
|
||||
|
||||
## Descrizione e scopo di ogni pagina/modulo richiesto
|
||||
|
||||
| Pagina | URL (V1 → V2) | Titolo pagina | Scopo |
|
||||
|---|---|---|---|
|
||||
| **Dashboard** | `dashboard.html` → `dashboard-bi.html` | "Dashboard" | Panoramica iniziale post-login: stato complessivo della conformità, sintesi e accesso rapido ai moduli. È la home dell'app. |
|
||||
| **Gap Analysis (Assessment)** | `assessment.html` → `assessment-bi.html` | "Gap Analysis" | Questionario di valutazione della conformità e analisi degli scostamenti (gap) rispetto ai requisiti. |
|
||||
| **Gap Analysis ACN** | `acn-gap.html` (non ancora -bi) | "Gap Analysis ACN" | Valutazione di secondo livello dedicata alle misure/requisiti ACN. |
|
||||
| **Rischi** | `risks.html` → `risks-bi.html` | "Gestione Rischi" | Registro dei rischi, valutazione e trattamento dei rischi. |
|
||||
| **Incidenti** | `incidents.html` → `incidents-bi.html` | "Gestione Incidenti" | Registrazione e gestione del ciclo di vita degli incidenti. |
|
||||
| **Policy** | `policies.html` → `policies-bi.html` | "Policy" | Gestione, redazione e approvazione delle policy/documenti. |
|
||||
| **Supply Chain** | `supply-chain.html` → `supply-chain-bi.html` | "Supply Chain" | Gestione fornitori, valutazioni e panoramica del rischio della catena di fornitura. |
|
||||
| **Training (Formazione)** | `training.html` → `training-bi.html` | "Formazione" | Corsi, assegnazioni e stato della formazione. |
|
||||
| **Asset** | `assets.html` → `assets-bi.html` | "Inventario Asset" | Inventario degli asset e relative informazioni. |
|
||||
| **Reports (Audit & Report)** | `reports.html` → `reports-bi.html` | "Audit e Report" | Controlli di audit, evidenze e reportistica di conformità. |
|
||||
| **Settings (Impostazioni)** | `settings.html` → `settings-bi.html` | "Impostazioni" | Impostazioni dell'organizzazione, del profilo utente e gestione dei membri. |
|
||||
| **ISMS / Modello SGSI** | `isms.html` (non ancora -bi) | "Modello Organizzativo SGSI (ISO 27001)" | Modulo Sistema di Gestione della Sicurezza delle Informazioni (ISO 27001). |
|
||||
| **Normative** | `normative.html` (non ancora -bi) | "Aggiornamenti Normativi" | Feed/elenco degli aggiornamenti normativi. |
|
||||
| **Whistleblowing (Segnalazioni)** | `whistleblowing.html` (non ancora -bi) | "Segnalazioni" | Gestione delle segnalazioni/whistleblowing. |
|
||||
| **Knowledge Base** | `kb.html` (non ancora -bi) | "Knowledge Base" | Base di conoscenza documentale: caricamento e consultazione documenti. |
|
||||
| **Cross-Analysis** | `cross-analysis.html` (non ancora -bi) | "AI Cross-Analysis" | Analisi incrociata dei dati di conformità assistita da AI. |
|
||||
| **Service Continuity** | `service-continuity.html` (non ancora -bi, fuori sidebar) | "Standard Continuità di Servizio" | Pagina dedicata alla continuità di servizio; raggiungibile via link contestuale, non dal menu. |
|
||||
|
||||
---
|
||||
|
||||
## Come navigare nella V2
|
||||
|
||||
1. **Accesso**: dopo il login (`login-bi.html`) l'utente arriva alla **Dashboard** (`dashboard-bi.html`), che è il punto di partenza.
|
||||
2. **Menu laterale (sidebar)**: tutte le funzioni si raggiungono dalla sidebar a sinistra, organizzata nelle 4 sezioni Principale / Gestione / Operativo / Sistema. La voce della pagina corrente è evidenziata (stato "active", `aria-current="page"`).
|
||||
3. **Cambio lingua**: in fondo alla sidebar, i pulsanti **IT** / **EN** cambiano la lingua dell'interfaccia.
|
||||
4. **Box utente e Logout**: in fondo alla sidebar sono mostrati avatar, nome e ruolo dell'utente; il pulsante con l'icona di uscita esegue il logout.
|
||||
5. **Selettore organizzazione (consulenti)**: per gli utenti che gestiscono più organizzazioni, sopra il menu può comparire un selettore per cambiare l'organizzazione attiva.
|
||||
6. **Versione app**: in fondo alla sidebar è indicata la versione dell'applicazione.
|
||||
7. **Mobile**: su schermi piccoli la sidebar diventa un drawer apribile (toggle), coerente con la conversione mobile.
|
||||
|
||||
### Dove trovare le funzioni principali (mappa rapida per ARIA)
|
||||
- "Vedere lo stato di conformità / panoramica" → **Dashboard** (sezione Principale).
|
||||
- "Fare la valutazione / questionario / gap" → **Gap Analysis** (assessment); per la parte ACN → **Gap Analysis ACN**.
|
||||
- "Gestire i rischi" → **Rischi** (sezione Gestione).
|
||||
- "Segnalare o gestire un incidente" → **Incidenti** (sezione Gestione).
|
||||
- "Documenti e policy" → **Policy** (sezione Gestione).
|
||||
- "Fornitori / catena di fornitura" → **Supply Chain** (sezione Gestione).
|
||||
- "Fare una segnalazione (whistleblowing)" → **Segnalazioni** (sezione Gestione).
|
||||
- "Aggiornamenti normativi" → **Normative** (sezione Gestione).
|
||||
- "Analisi AI incrociata" → **AI Cross-Analysis** (sezione Gestione).
|
||||
- "Cercare/caricare documenti di conoscenza" → **Knowledge Base** (sezione Gestione).
|
||||
- "Corsi e formazione" → **Formazione** (sezione Operativo).
|
||||
- "Inventario asset" → **Asset** (sezione Operativo).
|
||||
- "Report, audit, evidenze, export" → **Audit & Report** (sezione Operativo).
|
||||
- "Modello SGSI / ISO 27001" → **Modello SGSI (ISO 27001)** (sezione Principale).
|
||||
- "Impostazioni, profilo, membri, organizzazione" → **Impostazioni** (sezione Sistema).
|
||||
- "Aiuto / manuale" → **Guida all'uso** (sezione Sistema).
|
||||
- "Continuità di servizio" → pagina **Service Continuity** (link contestuale, non in sidebar).
|
||||
|
||||
---
|
||||
|
||||
## Note tecniche utili (per coerenza, non normative)
|
||||
- La sidebar V2 è generata da `public/js/common-bi.js`, che deve essere caricato **dopo** `common.js` e ne ridefinisce solo `loadSidebar()`.
|
||||
- L'albero di navigazione (nomi voci, sezioni, link) è lo **stesso** della V1 (`common.js`): cambiando l'una va aggiornata anche l'altra per mantenere parità.
|
||||
- Le pagine non ancora `-bi` sono comunque navigabili senza link rotti.
|
||||
|
||||
---
|
||||
|
||||
## Come ingestare questo documento nella KB (collection `nis2_kb`, scope SYSTEM)
|
||||
|
||||
Questo documento è pronto per essere indicizzato nella Knowledge Base RAG così che ARIA possa citarlo. L'ingest richiede accesso a Qdrant (`nis2_kb`) + Voyage, disponibile solo dall'host Hetzner / dal container `nis2-app` (NON dal devenv, che non raggiunge l'infrastruttura).
|
||||
|
||||
### Opzione A — endpoint API (super_admin, scope SYSTEM)
|
||||
|
||||
`POST /api/knowledgebase/ingest` richiede un JWT di un utente `super_admin` (lo scope SYSTEM è consentito solo ai super_admin). Limite testo 50.000 char (questo doc è ben sotto).
|
||||
|
||||
```bash
|
||||
# 1) login come super_admin -> ottieni $TOKEN
|
||||
TOKEN=$(curl -s -X POST https://nis2.agile.software/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"<SUPER_ADMIN_EMAIL>","password":"<PWD>"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
|
||||
|
||||
# 2) ingest del markdown come testo (scope SYSTEM)
|
||||
curl -s -X POST https://nis2.agile.software/api/knowledgebase/ingest \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @<(python3 - <<'PY'
|
||||
import json
|
||||
txt=open("/var/www/nis2-agile/docs/kb/V2_UI_NAVIGAZIONE.md").read()
|
||||
print(json.dumps({
|
||||
"title":"Interfaccia V2 (Bootstrap Italia) — struttura e navigazione UI",
|
||||
"text":txt,
|
||||
"entity_type":"ui_doc",
|
||||
"source":"Documentazione prodotto NIS2 Agile — UI V2 (Bootstrap Italia)",
|
||||
"scope":"SYSTEM"
|
||||
}))
|
||||
PY
|
||||
)
|
||||
```
|
||||
|
||||
### Opzione B — dal container `nis2-app` con uno script di ingest testo
|
||||
|
||||
Lo script di riferimento `scripts/ingest-nis2-sources.php` indicizza i **PDF normativi** del registry `nis2_sources.php` (non file markdown arbitrari). Per ingestare questo `.md` come testo SYSTEM, il modo coerente è chiamare l'endpoint `ingest` (Opzione A) oppure usare direttamente `EmbedService` + `VectorService` (`ensureCollection` → `upsertBatch`) con payload `scope=SYSTEM`, `source="Documentazione prodotto NIS2 Agile — UI V2"`, come fa lo script di riferimento per i punti SYSTEM. Esecuzione tipica dal container:
|
||||
|
||||
```bash
|
||||
docker exec -i nis2-app php /var/www/nis2-agile/scripts/ingest-nis2-sources.php # (riferimento: pipeline embed+upsert SYSTEM)
|
||||
```
|
||||
|
||||
### Stato ingest da questo ambiente (devenv)
|
||||
|
||||
L'ingest NON è stato eseguito da qui: il container/infra non è raggiungibile dal devenv.
|
||||
- `docker` CLI: assente nel devenv.
|
||||
- Qdrant `172.21.0.5:6333` e `nis2-qdrant:6333`: irraggiungibili (HTTP 000).
|
||||
- `POST /api/knowledgebase/ingest` su produzione: risponde 401 senza JWT super_admin (nessuna credenziale disponibile in questo ambiente).
|
||||
|
||||
Azione richiesta: eseguire l'Opzione A (o B) dall'host Hetzner / container `nis2-app` con credenziali super_admin. Idempotenza: per re-ingest, cancellare prima i chunk SYSTEM con lo stesso `source` (filtro `scope=SYSTEM` + `source=...`) come fa lo script di riferimento, poi ri-upsert.
|
||||
@@ -0,0 +1,438 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gap Analysis ACN (BI) - 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 (griglia misure/requisiti, scoring) -> parita' funzionale assoluta.
|
||||
BI veste la chrome (sidebar, font, bottoni, icone sprite). -->
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
/* Stili specifici della pagina ACN — identici alla live acn-gap.html
|
||||
(variabili --primary/--gray-* del design system esistente) */
|
||||
.acn-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; }
|
||||
.acn-level-badge { display:inline-block; padding:4px 12px; border-radius:999px; font-size:.8rem; font-weight:700; }
|
||||
.acn-level-important { background:#fef3c7; color:#92400e; }
|
||||
.acn-level-essential { background:#fee2e2; color:#991b1b; }
|
||||
.acn-func { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; margin-bottom:14px; overflow:hidden; }
|
||||
.acn-func-head { background:var(--gray-50,#f9fafb); padding:12px 16px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; gap:12px; }
|
||||
.acn-func-head strong { font-size:1rem; }
|
||||
.acn-func-meta { font-size:.8rem; color:var(--gray-500,#6b7280); }
|
||||
.acn-func-body { padding:8px 16px 16px; display:none; }
|
||||
.acn-func.open .acn-func-body { display:block; }
|
||||
.acn-measure { border-top:1px solid var(--gray-100,#f3f4f6); padding:14px 0; }
|
||||
.acn-measure-code { display:inline-block; background:#eef2ff; color:#3730a3; font-weight:700; font-size:.72rem; padding:2px 8px; border-radius:6px; margin-right:8px; }
|
||||
.acn-measure-title { font-weight:600; font-size:.92rem; }
|
||||
.acn-req { padding:12px 0 12px 14px; border-left:2px solid var(--gray-100,#f3f4f6); margin:10px 0 10px 8px; }
|
||||
.acn-req-text { font-size:.9rem; margin-bottom:8px; }
|
||||
.acn-req-idx { color:var(--gray-400,#9ca3af); font-weight:700; margin-right:6px; }
|
||||
.acn-opts { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.acn-opt { min-height:38px; padding:6px 12px; border:1.5px solid var(--gray-200,#e5e7eb); border-radius:20px; background:#fff; cursor:pointer; font-size:.84rem; font-weight:600; }
|
||||
.acn-opt:focus-visible { outline:2px solid var(--primary,#2563eb); outline-offset:2px; }
|
||||
.acn-opt.sel-implemented { background:#16a34a; color:#fff; border-color:#16a34a; }
|
||||
.acn-opt.sel-partial { background:#f59e0b; color:#fff; border-color:#f59e0b; }
|
||||
.acn-opt.sel-not_implemented { background:#dc2626; color:#fff; border-color:#dc2626; }
|
||||
.acn-opt.sel-not_applicable { background:#6b7280; color:#fff; border-color:#6b7280; }
|
||||
.acn-progress { position:sticky; top:0; background:#fff; z-index:5; padding:12px 0; border-bottom:1px solid var(--gray-100,#f3f4f6); }
|
||||
.acn-bar { height:8px; background:var(--gray-100,#f3f4f6); border-radius:4px; overflow:hidden; margin:8px 0; }
|
||||
.acn-bar-fill { height:100%; background:var(--primary,#2563eb); width:0; transition:width .3s; }
|
||||
.acn-func-score { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--gray-100,#f3f4f6); font-size:.9rem; }
|
||||
.acn-score-pill { font-weight:700; padding:2px 10px; border-radius:6px; }
|
||||
.savehint { font-size:.78rem; color:var(--gray-400,#9ca3af); min-height:16px; }
|
||||
/* icona inline nei bottoni BI */
|
||||
.btn .icon { fill:currentColor; vertical-align:-2px; margin-right:6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<h1 class="h2" data-i18n="acn.title">Gap Analysis ACN (misure di base)</h1>
|
||||
<div class="content-header-actions">
|
||||
<button class="btn btn-outline btn-sm" id="btn-ai-analyze" style="display:none;" onclick="acnAiAnalyze()" data-i18n="acn.ai">
|
||||
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-star-full"></use></svg>Analisi AI dei gap</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-body">
|
||||
<div class="acn-intro" id="acn-intro">
|
||||
<strong data-i18n="acn.intro.title">Conformità puntuale alla Determinazione ACN 164179/2025.</strong>
|
||||
<span data-i18n="acn.intro.body">Questa analisi va oltre le 10 misure generiche dell'Art. 21: valuta le misure e i requisiti puntuali stabiliti dall'ACN per i soggetti NIS. Il perimetro dipende dalla classificazione del soggetto (importante o essenziale).</span>
|
||||
</div>
|
||||
|
||||
<!-- START -->
|
||||
<div id="acn-start" class="card mb-24">
|
||||
<div class="card-body" style="padding:32px; text-align:center;">
|
||||
<div id="acn-level-info" class="mb-24"></div>
|
||||
<div id="acn-existing" class="mb-24" style="display:none; text-align:left;"></div>
|
||||
<button class="btn btn-primary btn-lg" id="btn-new-acn" onclick="createAcn()" data-i18n="acn.new">
|
||||
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-plus"></use></svg>Nuova Gap Analysis ACN</button>
|
||||
<div style="margin-top:14px;">
|
||||
<button class="btn btn-outline btn-sm" onclick="showCatalog()" data-i18n="acn.catalog.btn">
|
||||
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-search"></use></svg>Consulta il catalogo dei requisiti</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CATALOG (read-only) -->
|
||||
<div id="acn-catalog" class="card mb-24" style="display:none;">
|
||||
<div class="card-header"><h3 data-i18n="acn.catalog.title">Catalogo requisiti ACN applicabili</h3></div>
|
||||
<div class="card-body" id="acn-catalog-body"></div>
|
||||
</div>
|
||||
|
||||
<!-- WIZARD -->
|
||||
<div id="acn-wizard" style="display:none;">
|
||||
<div class="card acn-progress">
|
||||
<div class="card-body" style="padding:14px 18px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
|
||||
<strong id="acn-wizard-title">Gap Analysis ACN</strong>
|
||||
<span id="acn-wizard-level"></span>
|
||||
</div>
|
||||
<div class="acn-bar"><div class="acn-bar-fill" id="acn-bar-fill"></div></div>
|
||||
<div style="font-size:.82rem; color:var(--gray-500,#6b7280);"><span id="acn-answered">0</span> / <span id="acn-total">0</span> <span data-i18n="acn.answered">requisiti con risposta</span></div>
|
||||
<div class="savehint" id="acn-savehint" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-24">
|
||||
<div class="card-body" id="acn-functions"></div>
|
||||
</div>
|
||||
<div class="card mb-24">
|
||||
<div class="card-body" style="text-align:center;">
|
||||
<div id="acn-wizard-msg"></div>
|
||||
<button class="btn btn-primary btn-lg" id="btn-acn-complete" onclick="completeAcn()" data-i18n="acn.complete">
|
||||
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-chart-line"></use></svg>Calcola punteggio e completa</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RESULTS -->
|
||||
<div id="acn-results" style="display:none;">
|
||||
<div class="card mb-24">
|
||||
<div class="card-header"><h3 data-i18n="acn.results.title">Esito Gap Analysis ACN</h3></div>
|
||||
<div class="card-body">
|
||||
<div style="text-align:center; margin-bottom:20px;">
|
||||
<div style="font-size:3rem; font-weight:800;" id="acn-overall">0</div>
|
||||
<div class="text-muted" data-i18n="acn.results.overall">Punteggio complessivo di conformità (0-100)</div>
|
||||
</div>
|
||||
<h4 style="margin:16px 0 8px;" data-i18n="acn.results.byfunc">Punteggio per funzione (Framework Nazionale)</h4>
|
||||
<div id="acn-func-scores"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-24" id="acn-ai-card" style="display:none;">
|
||||
<div class="card-header"><h3 data-i18n="acn.results.ai">Analisi AI dei gap</h3></div>
|
||||
<div class="card-body" id="acn-ai-body"></div>
|
||||
</div>
|
||||
<div class="card mb-24">
|
||||
<div class="card-header"><h3 data-i18n="acn.results.gaps">Requisiti non conformi (piano d'azione)</h3></div>
|
||||
<div class="card-body" id="acn-gaps"></div>
|
||||
</div>
|
||||
<div style="text-align:center; margin-bottom:24px;">
|
||||
<button class="btn btn-outline" onclick="location.reload()" data-i18n="acn.backstart">
|
||||
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-arrow-left"></use></svg>Torna all'inizio</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/common.js"></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"></script>
|
||||
<script src="/js/i18n.js"></script>
|
||||
<script src="/js/help.js"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
const FUNC_LABELS = {
|
||||
GOVERN: { it:'Governo (GV)', en:'Govern (GV)' },
|
||||
IDENTIFY: { it:'Identificazione (ID)', en:'Identify (ID)' },
|
||||
PROTECT: { it:'Protezione (PR)', en:'Protect (PR)' },
|
||||
DETECT: { it:'Rilevamento (DE)', en:'Detect (DE)' },
|
||||
RESPOND: { it:'Risposta (RS)', en:'Respond (RS)' },
|
||||
RECOVER: { it:'Ripristino (RC)', en:'Recover (RC)' }
|
||||
};
|
||||
let ACN = { id:null, level:null, answers:{}, total:0 };
|
||||
let saveTimer = null;
|
||||
|
||||
function lang(){ try { return I18n.getLang ? I18n.getLang() : 'it'; } catch(e){ return 'it'; } }
|
||||
function el(id){ return document.getElementById(id); }
|
||||
function esc(s){ const d=document.createElement('div'); d.textContent=s==null?'':String(s); return d.innerHTML; }
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function(){
|
||||
if (typeof checkAuth==='function' && !checkAuth()) return;
|
||||
if (window.I18n && I18n.init) I18n.init('it');
|
||||
if (typeof loadSidebar==='function') loadSidebar();
|
||||
if (window.HelpSystem && HelpSystem.init) HelpSystem.init();
|
||||
await initStart();
|
||||
});
|
||||
|
||||
async function initStart(){
|
||||
try {
|
||||
const cat = await api.acnCatalog();
|
||||
ACN.level = cat.entity_level;
|
||||
const lvlTxt = cat.entity_level === 'essential'
|
||||
? '<span class="acn-level-badge acn-level-essential">'+(lang()==='en'?'ESSENTIAL entity':'Soggetto ESSENZIALE')+'</span>'
|
||||
: '<span class="acn-level-badge acn-level-important">'+(lang()==='en'?'IMPORTANT entity':'Soggetto IMPORTANTE')+'</span>';
|
||||
el('acn-level-info').innerHTML = lvlTxt +
|
||||
'<p class="text-muted" style="margin-top:10px;">' +
|
||||
(lang()==='en'?'Applicable scope: ':'Perimetro applicabile: ') +
|
||||
'<strong>'+cat.totals.measures+'</strong> '+(lang()==='en'?'measures':'misure')+', <strong>'+
|
||||
cat.totals.requirements+'</strong> '+(lang()==='en'?'requirements':'requisiti')+'.</p>';
|
||||
window._acnCatalog = cat;
|
||||
} catch(e){
|
||||
if (e && e.error_code === 'ENTITY_LEVEL_REQUIRED') {
|
||||
el('acn-level-info').innerHTML = '<p class="text-muted">'+(lang()==='en'
|
||||
?'Classify the organization as important or essential first.'
|
||||
:'Classifica prima il soggetto come importante o essenziale (Impostazioni › Classificazione NIS2).')+'</p>';
|
||||
} else {
|
||||
el('acn-level-info').innerHTML = '<p class="text-muted">'+(lang()==='en'?'Could not load.':'Caricamento non riuscito.')+'</p>';
|
||||
}
|
||||
}
|
||||
try {
|
||||
const list = await api.acnList();
|
||||
if (list.assessments && list.assessments.length){
|
||||
let html = '<h4 style="margin-bottom:8px;">'+(lang()==='en'?'Existing assessments':'Assessment esistenti')+'</h4>';
|
||||
list.assessments.forEach(function(a){
|
||||
const st = a.status==='completed'
|
||||
? '<span class="badge badge-success">'+(lang()==='en'?'Completed':'Completato')+(a.overall_score!=null?(' · '+a.overall_score):'')+'</span>'
|
||||
: '<span class="badge badge-warning">'+(lang()==='en'?'In progress':'In corso')+'</span>';
|
||||
html += '<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid var(--gray-100,#f3f4f6);">'+
|
||||
'<div><strong>'+esc(a.title)+'</strong> '+st+'</div>'+
|
||||
'<button class="btn btn-sm btn-outline" onclick="openAcn('+a.id+',\''+a.status+'\')">'+
|
||||
(a.status==='completed'?(lang()==='en'?'View':'Vedi'):(lang()==='en'?'Resume':'Riprendi'))+'</button></div>';
|
||||
});
|
||||
el('acn-existing').innerHTML = html;
|
||||
el('acn-existing').style.display='block';
|
||||
}
|
||||
} catch(e){}
|
||||
}
|
||||
|
||||
async function showCatalog(){
|
||||
const cat = window._acnCatalog;
|
||||
if (!cat){ return; }
|
||||
let html = '';
|
||||
(cat.functions||[]).forEach(function(f){
|
||||
html += '<div class="acn-func open"><div class="acn-func-head" onclick="this.parentNode.classList.toggle(\'open\')">'+
|
||||
'<strong>'+esc(FUNC_LABELS[f.function_code]?FUNC_LABELS[f.function_code][lang()]:f.function_name)+'</strong>'+
|
||||
'<span class="acn-func-meta">'+(f.measures||[]).length+' '+(lang()==='en'?'measures':'misure')+'</span></div>'+
|
||||
'<div class="acn-func-body">';
|
||||
(f.measures||[]).forEach(function(m){
|
||||
html += '<div class="acn-measure"><span class="acn-measure-code">'+esc(m.measure_code)+'</span>'+
|
||||
'<span class="acn-measure-title">'+esc(m.measure_title)+'</span>';
|
||||
(m.requirements||[]).forEach(function(r){
|
||||
html += '<div class="acn-req"><span class="acn-req-idx">'+r.index+'.</span>'+esc(r.text)+'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
});
|
||||
el('acn-catalog-body').innerHTML = html;
|
||||
el('acn-catalog').style.display='block';
|
||||
el('acn-catalog').scrollIntoView({behavior:'smooth'});
|
||||
}
|
||||
|
||||
async function createAcn(){
|
||||
try {
|
||||
const res = await api.acnCreate({});
|
||||
await openAcn(res.id, 'draft');
|
||||
} catch(e){
|
||||
alert((e && e.message) || (lang()==='en'?'Creation failed':'Creazione non riuscita'));
|
||||
}
|
||||
}
|
||||
|
||||
async function openAcn(id, status){
|
||||
try {
|
||||
ACN.id = id;
|
||||
const res = await api.acnRequirements(id);
|
||||
ACN.level = res.assessment.entity_level;
|
||||
if (res.assessment.status === 'completed'){
|
||||
await showResults(id);
|
||||
return;
|
||||
}
|
||||
renderWizard(res);
|
||||
} catch(e){
|
||||
alert((e && e.message) || (lang()==='en'?'Could not open the assessment.':'Impossibile aprire l\'assessment.'));
|
||||
}
|
||||
}
|
||||
|
||||
function renderWizard(res){
|
||||
el('acn-start').style.display='none';
|
||||
el('acn-catalog').style.display='none';
|
||||
el('acn-results').style.display='none';
|
||||
el('acn-wizard').style.display='block';
|
||||
el('btn-ai-analyze').style.display='none';
|
||||
el('acn-wizard-level').innerHTML = ACN.level==='essential'
|
||||
? '<span class="acn-level-badge acn-level-essential">'+(lang()==='en'?'ESSENTIAL':'ESSENZIALE')+'</span>'
|
||||
: '<span class="acn-level-badge acn-level-important">'+(lang()==='en'?'IMPORTANT':'IMPORTANTE')+'</span>';
|
||||
|
||||
ACN.answers = {}; ACN.total = 0;
|
||||
let html = '';
|
||||
(res.functions||[]).forEach(function(f, fi){
|
||||
let fcount = 0;
|
||||
let inner = '';
|
||||
(f.categories||[]).forEach(function(c){
|
||||
(c.measures||[]).forEach(function(m){
|
||||
inner += '<div class="acn-measure"><span class="acn-measure-code">'+esc(m.measure_code)+'</span>'+
|
||||
'<span class="acn-measure-title">'+esc(m.measure_title)+'</span>';
|
||||
(m.requirements||[]).forEach(function(r){
|
||||
ACN.total++;
|
||||
fcount++;
|
||||
if (r.response) ACN.answers[r.key] = r.response;
|
||||
inner += renderReq(r);
|
||||
});
|
||||
inner += '</div>';
|
||||
});
|
||||
});
|
||||
html += '<div class="acn-func'+(fi===0?' open':'')+'">'+
|
||||
'<div class="acn-func-head" onclick="this.parentNode.classList.toggle(\'open\')">'+
|
||||
'<strong>'+esc(FUNC_LABELS[f.function_code]?FUNC_LABELS[f.function_code][lang()]:f.function_name)+'</strong>'+
|
||||
'<span class="acn-func-meta">'+fcount+' '+(lang()==='en'?'requirements':'requisiti')+'</span></div>'+
|
||||
'<div class="acn-func-body">'+inner+'</div></div>';
|
||||
});
|
||||
el('acn-functions').innerHTML = html;
|
||||
el('acn-total').textContent = ACN.total;
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function renderReq(r){
|
||||
const opts = [
|
||||
['implemented', lang()==='en'?'Implemented':'Attuato'],
|
||||
['partial', lang()==='en'?'Partial':'Parziale'],
|
||||
['not_implemented', lang()==='en'?'Not implemented':'Non attuato'],
|
||||
['not_applicable', lang()==='en'?'N/A':'Non applic.']
|
||||
];
|
||||
let btns = '';
|
||||
opts.forEach(function(o){
|
||||
const sel = r.response===o[0] ? ('sel-'+o[0]) : '';
|
||||
btns += '<button type="button" class="acn-opt '+sel+'" data-key="'+esc(r.key)+'" data-val="'+o[0]+'" onclick="pick(this)">'+o[1]+'</button>';
|
||||
});
|
||||
return '<div class="acn-req"><div class="acn-req-text"><span class="acn-req-idx">'+r.index+'.</span>'+esc(r.text)+'</div>'+
|
||||
'<div class="acn-opts">'+btns+'</div></div>';
|
||||
}
|
||||
|
||||
function pick(btn){
|
||||
const key = btn.getAttribute('data-key');
|
||||
const val = btn.getAttribute('data-val');
|
||||
const wrap = btn.parentNode;
|
||||
Array.prototype.forEach.call(wrap.querySelectorAll('.acn-opt'), function(b){
|
||||
b.className = 'acn-opt';
|
||||
});
|
||||
btn.className = 'acn-opt sel-'+val;
|
||||
ACN.answers[key] = val;
|
||||
updateProgress();
|
||||
scheduleSave(key, val);
|
||||
}
|
||||
|
||||
let pendingSaves = {};
|
||||
function scheduleSave(key, val){
|
||||
pendingSaves[key] = val;
|
||||
el('acn-savehint').textContent = lang()==='en'?'Saving…':'Salvataggio…';
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(flushSaves, 700);
|
||||
}
|
||||
async function flushSaves(){
|
||||
const batch = Object.keys(pendingSaves).map(function(k){ return { requirement_key:k, response_value:pendingSaves[k] }; });
|
||||
if (!batch.length) return;
|
||||
pendingSaves = {};
|
||||
try {
|
||||
await api.acnRespond(ACN.id, { responses: batch });
|
||||
el('acn-savehint').textContent = (lang()==='en'?'Saved ':'Salvato ')+new Date().toLocaleTimeString(lang()==='en'?'en-GB':'it-IT').slice(0,5);
|
||||
} catch(e){
|
||||
el('acn-savehint').textContent = lang()==='en'?'Save pending':'Salvataggio in sospeso';
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress(){
|
||||
const answered = Object.keys(ACN.answers).length;
|
||||
el('acn-answered').textContent = answered;
|
||||
el('acn-bar-fill').style.width = (ACN.total? Math.round(answered/ACN.total*100):0)+'%';
|
||||
}
|
||||
|
||||
async function completeAcn(){
|
||||
await flushSaves();
|
||||
const answered = Object.keys(ACN.answers).length;
|
||||
if (answered < ACN.total){
|
||||
el('acn-wizard-msg').innerHTML = '<div class="alert alert-warning">'+
|
||||
(lang()==='en'?('Missing '+(ACN.total-answered)+' answers.'):('Mancano '+(ACN.total-answered)+' risposte.'))+'</div>';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.acnComplete(ACN.id);
|
||||
await showResults(ACN.id);
|
||||
} catch(e){
|
||||
el('acn-wizard-msg').innerHTML = '<div class="alert alert-warning">'+esc((e&&e.message)||'Errore')+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function showResults(id){
|
||||
const rep = await api.acnReport(id);
|
||||
const a = rep.assessment;
|
||||
el('acn-start').style.display='none';
|
||||
el('acn-wizard').style.display='none';
|
||||
el('acn-catalog').style.display='none';
|
||||
el('acn-results').style.display='block';
|
||||
el('btn-ai-analyze').style.display='inline-flex';
|
||||
ACN.id = id;
|
||||
el('acn-overall').textContent = a.overall_score!=null ? a.overall_score : '—';
|
||||
el('acn-overall').style.color = scoreColor(a.overall_score);
|
||||
|
||||
let fs = '';
|
||||
const fscores = a.function_scores || {};
|
||||
Object.keys(FUNC_LABELS).forEach(function(fc){
|
||||
if (fscores[fc]==null) return;
|
||||
fs += '<div class="acn-func-score"><span>'+esc(FUNC_LABELS[fc][lang()])+'</span>'+
|
||||
'<span class="acn-score-pill" style="background:'+scoreBg(fscores[fc])+'; color:'+scoreColor(fscores[fc])+';">'+fscores[fc]+'</span></div>';
|
||||
});
|
||||
el('acn-func-scores').innerHTML = fs;
|
||||
|
||||
let g = '';
|
||||
(rep.gaps||[]).forEach(function(gap){
|
||||
const tag = gap.response_value==='not_implemented'
|
||||
? '<span class="badge badge-danger">'+(lang()==='en'?'Not implemented':'Non attuato')+'</span>'
|
||||
: '<span class="badge badge-warning">'+(lang()==='en'?'Partial':'Parziale')+'</span>';
|
||||
g += '<div style="padding:10px 0; border-bottom:1px solid var(--gray-100,#f3f4f6);">'+
|
||||
'<span class="acn-measure-code">'+esc(gap.measure_code)+'</span> '+tag+
|
||||
'<div style="font-size:.88rem; margin-top:4px;">'+esc(gap.requirement_text)+'</div></div>';
|
||||
});
|
||||
el('acn-gaps').innerHTML = g || '<p class="text-muted">'+(lang()==='en'?'No gaps. Fully compliant.':'Nessun gap: pienamente conforme.')+'</p>';
|
||||
|
||||
if (a.ai_summary){
|
||||
el('acn-ai-body').innerHTML = '<div style="white-space:pre-wrap; font-size:.9rem;">'+esc(a.ai_summary)+'</div>';
|
||||
el('acn-ai-card').style.display='block';
|
||||
}
|
||||
}
|
||||
|
||||
async function acnAiAnalyze(){
|
||||
const btn = el('btn-ai-analyze');
|
||||
btn.disabled = true;
|
||||
const old = btn.textContent;
|
||||
btn.textContent = lang()==='en'?'Analyzing…':'Analisi in corso…';
|
||||
try {
|
||||
const res = await api.acnAiAnalyze(ACN.id);
|
||||
el('acn-ai-body').innerHTML = '<div style="white-space:pre-wrap; font-size:.9rem;">'+esc(res.ai_summary)+'</div>';
|
||||
el('acn-ai-card').style.display='block';
|
||||
el('acn-ai-card').scrollIntoView({behavior:'smooth'});
|
||||
} catch(e){
|
||||
alert((e&&e.message)||(lang()==='en'?'AI unavailable':'AI non disponibile'));
|
||||
} finally {
|
||||
btn.disabled=false; btn.textContent=old;
|
||||
}
|
||||
}
|
||||
|
||||
function scoreColor(s){ if(s==null)return '#6b7280'; if(s>=80)return '#16a34a'; if(s>=60)return '#65a30d'; if(s>=40)return '#f59e0b'; return '#dc2626'; }
|
||||
function scoreBg(s){ if(s==null)return '#f3f4f6'; if(s>=80)return '#f0fdf4'; if(s>=60)return '#f7fee7'; if(s>=40)return '#fffbeb'; return '#fef2f2'; }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,398 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pannello Amministrazione (BI) - 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">
|
||||
<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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-body">
|
||||
<!-- 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>
|
||||
<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>
|
||||
<div class="admin-stat-label" id="lbl-active-orgs">Organizzazioni Attive</div>
|
||||
<div class="admin-stat-value" id="stat-active-orgs" aria-labelledby="lbl-active-orgs">--</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-icon blue" aria-hidden="true">
|
||||
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
|
||||
</div>
|
||||
<div class="admin-stat-label" id="lbl-total-users">Totale Utenti</div>
|
||||
<div class="admin-stat-value" id="stat-total-users" aria-labelledby="lbl-total-users">--</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-icon green" aria-hidden="true">
|
||||
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
|
||||
</div>
|
||||
<div class="admin-stat-label" id="lbl-active-users">Utenti Attivi</div>
|
||||
<div class="admin-stat-value" id="stat-active-users" aria-labelledby="lbl-active-users">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div class="admin-stat-label" id="lbl-assessments">Assessment Completati</div>
|
||||
<div class="admin-stat-value" id="stat-assessments" aria-labelledby="lbl-assessments">--</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-icon orange" aria-hidden="true">
|
||||
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning-circle"></use></svg>
|
||||
</div>
|
||||
<div class="admin-stat-label" id="lbl-incidents">Incidenti Aperti</div>
|
||||
<div class="admin-stat-value" id="stat-incidents" aria-labelledby="lbl-incidents">--</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-icon red" aria-hidden="true">
|
||||
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-error"></use></svg>
|
||||
</div>
|
||||
<div class="admin-stat-label" id="lbl-risks">Totale Rischi</div>
|
||||
<div class="admin-stat-value" id="stat-risks" aria-labelledby="lbl-risks">--</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-icon purple" aria-hidden="true">
|
||||
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-software"></use></svg>
|
||||
</div>
|
||||
<div class="admin-stat-label" id="lbl-ai">Interazioni AI</div>
|
||||
<div class="admin-stat-value" id="stat-ai" aria-labelledby="lbl-ai">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plans Distribution -->
|
||||
<div class="card mb-24">
|
||||
<div class="card-header">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Gestione Piattaforma</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<nav class="quick-links-grid" aria-label="Collegamenti rapidi gestione piattaforma">
|
||||
<a href="/admin/organizations-bi.html" class="quick-link-card">
|
||||
<div class="quick-link-icon" aria-hidden="true">
|
||||
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg>
|
||||
</div>
|
||||
<div class="quick-link-info">
|
||||
<h4>Gestisci Organizzazioni</h4>
|
||||
<p>Visualizza e gestisci tutte le organizzazioni registrate sulla piattaforma.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/users-bi.html" class="quick-link-card">
|
||||
<div class="quick-link-icon" aria-hidden="true">
|
||||
<svg class="icon" role="img"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-user"></use></svg>
|
||||
</div>
|
||||
<div class="quick-link-info">
|
||||
<h4>Gestisci Utenti</h4>
|
||||
<p>Visualizza e gestisci tutti gli utenti registrati sulla piattaforma.</p>
|
||||
</div>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/common.js"></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"></script>
|
||||
<script src="/js/i18n.js"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
I18n.init();
|
||||
|
||||
// ── Admin check ──────────────────────────────────────────
|
||||
(async function checkAdmin() {
|
||||
try {
|
||||
const me = await api.getMe();
|
||||
if (!me.success || !me.data || me.data.role !== 'super_admin') {
|
||||
showNotification('Accesso non autorizzato. Solo gli amministratori possono accedere a questa pagina.', 'error');
|
||||
setTimeout(() => { window.location.href = '/dashboard.html'; }, 1500);
|
||||
return;
|
||||
}
|
||||
loadAdminStats();
|
||||
} catch (e) {
|
||||
window.location.href = '/dashboard.html';
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Load Stats ───────────────────────────────────────────
|
||||
async function loadAdminStats() {
|
||||
try {
|
||||
const result = await api.request('GET', '/admin/stats');
|
||||
if (result.success && result.data) {
|
||||
const d = result.data;
|
||||
|
||||
document.getElementById('stat-total-orgs').textContent = d.total_organizations ?? 0;
|
||||
document.getElementById('stat-active-orgs').textContent = d.active_organizations ?? 0;
|
||||
document.getElementById('stat-total-users').textContent = d.total_users ?? 0;
|
||||
document.getElementById('stat-active-users').textContent = d.active_users ?? 0;
|
||||
document.getElementById('stat-assessments').textContent = d.completed_assessments ?? 0;
|
||||
document.getElementById('stat-incidents').textContent = d.open_incidents ?? 0;
|
||||
document.getElementById('stat-risks').textContent = d.total_risks ?? 0;
|
||||
document.getElementById('stat-ai').textContent = d.ai_interactions ?? 0;
|
||||
|
||||
renderPlansChart(d.plans_distribution || []);
|
||||
} else {
|
||||
showNotification('Impossibile caricare le statistiche.', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Errore caricamento stats:', e);
|
||||
showNotification('Errore di connessione.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plans Chart ──────────────────────────────────────────
|
||||
function renderPlansChart(distribution) {
|
||||
const container = document.getElementById('plans-chart');
|
||||
|
||||
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };
|
||||
const plans = { free: 0, professional: 0, enterprise: 0 };
|
||||
|
||||
distribution.forEach(p => {
|
||||
const key = (p.subscription_plan || 'free').toLowerCase();
|
||||
if (plans.hasOwnProperty(key)) {
|
||||
plans[key] = parseInt(p.count) || 0;
|
||||
}
|
||||
});
|
||||
|
||||
const maxVal = Math.max(1, ...Object.values(plans));
|
||||
|
||||
let html = '';
|
||||
for (const [key, count] of Object.entries(plans)) {
|
||||
const heightPct = (count / maxVal) * 100;
|
||||
html += `
|
||||
<div class="bar-chart-item">
|
||||
<div class="bar-chart-value">${count}</div>
|
||||
<div class="bar-chart-bar ${key}" style="height:${Math.max(heightPct, 3)}%;"></div>
|
||||
<div class="bar-chart-label">${planLabels[key]}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,315 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gestione Organizzazioni (BI) - 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">
|
||||
<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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:0;">
|
||||
<div id="orgs-container">
|
||||
<div class="spinner" style="margin:60px auto;" role="status" aria-label="Caricamento in corso"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="../js/api.js"></script>
|
||||
<script src="../js/common.js"></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"></script>
|
||||
<script src="../js/i18n.js"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
I18n.init();
|
||||
|
||||
let currentPage = 1;
|
||||
|
||||
// ── Admin check ──────────────────────────────────────────
|
||||
(async function checkAdmin() {
|
||||
try {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
loadOrganizations(1);
|
||||
} catch (e) {
|
||||
window.location.href = '/dashboard.html';
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Mappature ────────────────────────────────────────────
|
||||
const sectorLabels = {
|
||||
energy: 'Energia', transport: 'Trasporti', banking: 'Banche',
|
||||
health: 'Sanita\'', water: 'Acqua', digital_infra: 'Infrastrutture Digitali',
|
||||
public_admin: 'Pubblica Amministrazione', manufacturing: 'Manifatturiero',
|
||||
postal: 'Servizi Postali', chemical: 'Chimico', food: 'Alimentare',
|
||||
waste: 'Rifiuti', ict_services: 'Servizi ICT',
|
||||
digital_providers: 'Provider Digitali', space: 'Spazio',
|
||||
research: 'Ricerca', other: 'Altro'
|
||||
};
|
||||
|
||||
const entityTypeLabels = {
|
||||
essential: 'Essenziale',
|
||||
important: 'Importante',
|
||||
not_applicable: 'N/A'
|
||||
};
|
||||
|
||||
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };
|
||||
|
||||
// ── 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>';
|
||||
currentPage = page;
|
||||
|
||||
try {
|
||||
const result = await api.request('GET', '/admin/organizations?page=' + page);
|
||||
if (result.success) {
|
||||
renderOrganizations(result.data || [], result.pagination || {});
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h4>Errore nel caricamento</h4>
|
||||
<p>${escapeHtml(result.message || 'Errore sconosciuto')}</p>
|
||||
</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderOrganizations(orgs, pagination) {
|
||||
const container = document.getElementById('orgs-container');
|
||||
|
||||
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>
|
||||
<h4>Nessuna organizzazione trovata</h4>
|
||||
<p>Le organizzazioni registrate appariranno qui.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Nome</th>
|
||||
<th scope="col">Settore</th>
|
||||
<th scope="col">Tipo Entita'</th>
|
||||
<th scope="col">Membri</th>
|
||||
<th scope="col">Ultimo Score</th>
|
||||
<th scope="col">Piano</th>
|
||||
<th scope="col">Stato</th>
|
||||
<th scope="col">Data Creazione</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
orgs.forEach(org => {
|
||||
const entityType = org.entity_type || 'not_applicable';
|
||||
const sector = sectorLabels[org.sector] || org.sector || '-';
|
||||
const entityLabel = entityTypeLabels[entityType] || entityType;
|
||||
const plan = (org.subscription_plan || 'free').toLowerCase();
|
||||
const planLabel = planLabels[plan] || plan;
|
||||
const isActive = org.is_active == 1 || org.is_active === true;
|
||||
const score = org.last_score;
|
||||
let scoreHtml = '<span class="text-muted">-</span>';
|
||||
|
||||
if (score !== null && score !== undefined) {
|
||||
const scoreColor = getScoreColor(score);
|
||||
scoreHtml = `
|
||||
<span class="score-inline">
|
||||
<span class="score-dot" style="background:${scoreColor}"></span>
|
||||
${Math.round(score)}%
|
||||
</span>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(org.name || '-')}</strong></td>
|
||||
<td>${escapeHtml(sector)}</td>
|
||||
<td><span class="entity-type-badge ${entityType}">${escapeHtml(entityLabel)}</span></td>
|
||||
<td>${org.member_count ?? '-'}</td>
|
||||
<td>${scoreHtml}</td>
|
||||
<td><span class="plan-badge ${plan}">${escapeHtml(planLabel)}</span></td>
|
||||
<td><span class="status-dot ${isActive ? 'active' : 'inactive'}">${isActive ? 'Attiva' : 'Inattiva'}</span></td>
|
||||
<td>${formatDate(org.created_at)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
|
||||
// Paginazione
|
||||
const total = pagination.total || orgs.length;
|
||||
const perPage = pagination.per_page || 20;
|
||||
const page = pagination.page || currentPage;
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
const start = ((page - 1) * perPage) + 1;
|
||||
const end = Math.min(page * perPage, total);
|
||||
|
||||
if (totalPages > 1) {
|
||||
html += `
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
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' : ''}>
|
||||
« Precedente
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="loadOrganizations(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
|
||||
Successivo »
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
} else if (total > 0) {
|
||||
html += `
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
Totale: ${total} organizzazion${total === 1 ? 'e' : 'i'}
|
||||
</div>
|
||||
<div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,387 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gestione Utenti (BI) - 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">
|
||||
<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);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:0;">
|
||||
<div id="users-container" aria-live="polite">
|
||||
<div class="spinner" style="margin:60px auto;" role="status" aria-label="Caricamento utenti"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="../js/api.js"></script>
|
||||
<script src="../js/common.js"></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"></script>
|
||||
<script src="../js/i18n.js"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
I18n.init();
|
||||
|
||||
let currentPage = 1;
|
||||
|
||||
// ── Admin check ──────────────────────────────────────────
|
||||
(async function checkAdmin() {
|
||||
try {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
loadUsers(1);
|
||||
} catch (e) {
|
||||
window.location.href = '../dashboard.html';
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Mappature ────────────────────────────────────────────
|
||||
const roleLabels = {
|
||||
super_admin: 'Super Admin',
|
||||
org_admin: 'Amministratore',
|
||||
compliance_manager: 'Compliance Manager',
|
||||
board_member: 'Membro CDA',
|
||||
auditor: 'Auditor',
|
||||
employee: 'Dipendente'
|
||||
};
|
||||
|
||||
// ── 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>';
|
||||
currentPage = page;
|
||||
|
||||
try {
|
||||
const result = await api.request('GET', '/admin/users?page=' + page);
|
||||
if (result.success) {
|
||||
renderUsers(result.data || [], result.pagination || {});
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h4>Errore nel caricamento</h4>
|
||||
<p>${escapeHtml(result.message || 'Errore sconosciuto')}</p>
|
||||
</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers(users, pagination) {
|
||||
const container = document.getElementById('users-container');
|
||||
|
||||
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>
|
||||
<h4>Nessun utente trovato</h4>
|
||||
<p>Gli utenti registrati appariranno qui.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Nome</th>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Ruolo</th>
|
||||
<th scope="col">Organizzazioni</th>
|
||||
<th scope="col">Ultimo Login</th>
|
||||
<th scope="col">Attivo</th>
|
||||
<th scope="col">Data Creazione</th>
|
||||
<th scope="col">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
users.forEach(user => {
|
||||
const role = user.role || 'employee';
|
||||
const roleLabel = roleLabels[role] || role;
|
||||
const isActive = user.is_active == 1 || user.is_active === true;
|
||||
|
||||
// Organizzazioni
|
||||
let orgsHtml = '<span class="text-muted">-</span>';
|
||||
if (user.organizations) {
|
||||
const orgNames = user.organizations.split(',').filter(n => n.trim());
|
||||
if (orgNames.length > 0) {
|
||||
orgsHtml = '<div class="org-tags">';
|
||||
orgNames.forEach(name => {
|
||||
orgsHtml += `<span class="org-tag" title="${escapeHtml(name.trim())}">${escapeHtml(name.trim())}</span>`;
|
||||
});
|
||||
orgsHtml += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimo login
|
||||
const lastLogin = user.last_login_at ? formatDateTime(user.last_login_at) : '<span class="text-muted">Mai</span>';
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(user.full_name || '-')}</strong></td>
|
||||
<td>${escapeHtml(user.email || '-')}</td>
|
||||
<td><span class="role-badge ${role}">${escapeHtml(roleLabel)}</span></td>
|
||||
<td>${orgsHtml}</td>
|
||||
<td>${lastLogin}</td>
|
||||
<td>
|
||||
<label class="active-toggle" title="${isActive ? 'Attivo' : 'Inattivo'}">
|
||||
<span class="visually-hidden">Stato attivo utente ${escapeHtml(user.email || '')}</span>
|
||||
<input type="checkbox" ${isActive ? 'checked' : ''} onchange="toggleUserActive(${user.id}, this.checked)" ${role === 'super_admin' ? 'disabled' : ''} aria-label="${isActive ? 'Disattiva' : 'Attiva'} utente ${escapeHtml(user.email || '')}">
|
||||
<span class="toggle-slider" aria-hidden="true"></span>
|
||||
</label>
|
||||
</td>
|
||||
<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>
|
||||
Impersonate
|
||||
</button>`
|
||||
: '<span class="text-muted">-</span>'}
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
|
||||
// Paginazione
|
||||
const total = pagination.total || users.length;
|
||||
const perPage = pagination.per_page || 20;
|
||||
const page = pagination.page || currentPage;
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
const start = ((page - 1) * perPage) + 1;
|
||||
const end = Math.min(page * perPage, total);
|
||||
|
||||
if (totalPages > 1) {
|
||||
html += `
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
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' : ''}>
|
||||
« Precedente
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="loadUsers(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
|
||||
Successivo »
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
} else if (total > 0) {
|
||||
html += `
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
Totale: ${total} utent${total === 1 ? 'e' : 'i'}
|
||||
</div>
|
||||
<div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Toggle Attivo/Inattivo ───────────────────────────────
|
||||
async function toggleUserActive(userId, isActive) {
|
||||
try {
|
||||
const result = await api.request('PUT', '/admin/users/' + userId, {
|
||||
is_active: isActive ? 1 : 0
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showNotification(
|
||||
isActive ? 'Utente attivato con successo.' : 'Utente disattivato con successo.',
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
showNotification(result.message || 'Errore nell\'aggiornamento.', 'error');
|
||||
// Ricarica per resettare lo stato
|
||||
loadUsers(currentPage);
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Errore di connessione.', 'error');
|
||||
loadUsers(currentPage);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Impersonate (Fase 4 / G11 — UI) ──
|
||||
async function impersonateUser(userId, email) {
|
||||
if (!confirm('Entrare come ' + email + ' per 1 ora?\n\nSarai loggato come quell\'utente. Tornerai alla tua sessione facendo logout.')) return;
|
||||
try {
|
||||
const res = await api.post('/auth/impersonate', { user_id: userId });
|
||||
if (res.success && res.data && res.data.access_token) {
|
||||
// Salva sessione originale per "Exit impersonate"
|
||||
sessionStorage.setItem('nis2_impersonate_origin_token', api.token);
|
||||
sessionStorage.setItem('nis2_impersonate_origin_refresh', api.refreshToken || '');
|
||||
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';
|
||||
} else {
|
||||
showNotification(res.message || 'Impersonate fallito.', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Errore di connessione.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,646 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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">
|
||||
<style>
|
||||
.companies-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<!-- Sidebar -->
|
||||
<aside id="sidebar" class="sidebar"></aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<div class="page-container">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="companies-header">
|
||||
<div>
|
||||
<h1>Le Mie Aziende</h1>
|
||||
<p style="color:var(--gray-500);font-size:.9rem;margin-top:2px;">Gestisci il portafoglio clienti e monitora la compliance NIS2</p>
|
||||
</div>
|
||||
<div class="companies-header-actions">
|
||||
<div class="search-box">
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
|
||||
<input type="text" class="form-input" id="search-input" placeholder="Cerca azienda..." oninput="filterCompanies(this.value)">
|
||||
</div>
|
||||
<a href="onboarding-bi.html" class="btn btn-primary">
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
Aggiungi Cliente
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Companies Grid -->
|
||||
<div class="companies-grid" id="companies-grid">
|
||||
<!-- Skeleton loaders -->
|
||||
<div class="skeleton-card">
|
||||
<div style="display:flex;gap:12px;align-items:center">
|
||||
<div class="skeleton" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div>
|
||||
<div style="flex:1"><div class="skeleton" style="height:16px;margin-bottom:8px"></div><div class="skeleton" style="height:12px;width:60%"></div></div>
|
||||
</div>
|
||||
<div class="skeleton" style="height:6px"></div>
|
||||
<div style="display:flex;gap:16px"><div class="skeleton" style="height:40px;flex:1"></div><div class="skeleton" style="height:40px;flex:1"></div></div>
|
||||
<div class="skeleton" style="height:36px"></div>
|
||||
</div>
|
||||
<div class="skeleton-card">
|
||||
<div style="display:flex;gap:12px;align-items:center">
|
||||
<div class="skeleton" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div>
|
||||
<div style="flex:1"><div class="skeleton" style="height:16px;margin-bottom:8px"></div><div class="skeleton" style="height:12px;width:60%"></div></div>
|
||||
</div>
|
||||
<div class="skeleton" style="height:6px"></div>
|
||||
<div style="display:flex;gap:16px"><div class="skeleton" style="height:40px;flex:1"></div><div class="skeleton" style="height:40px;flex:1"></div></div>
|
||||
<div class="skeleton" style="height:36px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (hidden initially) -->
|
||||
<div class="companies-empty" id="companies-empty" style="display:none">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z"/>
|
||||
</svg>
|
||||
<h3>Nessuna azienda ancora</h3>
|
||||
<p>Aggiungi il tuo primo cliente per iniziare a monitorare la compliance NIS2.</p>
|
||||
<a href="onboarding-bi.html" class="btn btn-primary">
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
Aggiungi Primo Cliente
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/common.js"></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"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/help.js"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
// Check that user is consultant, else redirect
|
||||
if (!api.isConsultant()) {
|
||||
window.location.href = 'dashboard-bi.html';
|
||||
throw new Error('Not consultant');
|
||||
}
|
||||
|
||||
loadSidebar();
|
||||
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
|
||||
|
||||
// ── Data ────────────────────────────────────────────────────────
|
||||
let allCompanies = [];
|
||||
|
||||
// ── Load companies ───────────────────────────────────────────────
|
||||
async function loadCompanies() {
|
||||
const result = await api.listOrganizations();
|
||||
const grid = document.getElementById('companies-grid');
|
||||
const emptyState = document.getElementById('companies-empty');
|
||||
|
||||
if (!result.success) {
|
||||
showNotification('Errore nel caricamento delle aziende.', 'error');
|
||||
grid.innerHTML = '';
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const orgs = result.data || [];
|
||||
allCompanies = orgs;
|
||||
|
||||
if (orgs.length === 0) {
|
||||
grid.innerHTML = '';
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
renderCompanies(orgs);
|
||||
|
||||
// Load compliance scores in background
|
||||
orgs.forEach(org => loadOrgScore(org.id || org.organization_id));
|
||||
}
|
||||
|
||||
// ── Render companies ─────────────────────────────────────────────
|
||||
function renderCompanies(orgs) {
|
||||
const grid = document.getElementById('companies-grid');
|
||||
grid.innerHTML = orgs.map(org => renderCompanyCard(org)).join('');
|
||||
}
|
||||
|
||||
function renderCompanyCard(org) {
|
||||
const orgId = org.id || org.organization_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 initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
|
||||
const entityBadge = renderEntityBadge(entityType, voluntary);
|
||||
const sectorLabel = getSectorLabel(sector);
|
||||
|
||||
return `
|
||||
<div class="company-card" data-name="${escapeHtml(name.toLowerCase())}" data-org-id="${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>
|
||||
${entityBadge}
|
||||
</div>
|
||||
|
||||
<!-- Score bar (loaded async) -->
|
||||
<div class="company-score-row" id="score-row-${orgId}" style="display:none">
|
||||
<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>
|
||||
<span class="company-score-value" id="score-val-${orgId}">-</span>
|
||||
</div>
|
||||
|
||||
<!-- Stats row (loaded async) -->
|
||||
<div class="company-stats" id="stats-row-${orgId}" style="display:none">
|
||||
<div class="company-stat">
|
||||
<span class="company-stat-value" id="stat-risks-${orgId}">-</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>
|
||||
</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">
|
||||
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>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Connettori per-azienda (Evidence Automation) ─────────────
|
||||
const CONNECTOR_LABELS = {
|
||||
m365: 'Microsoft 365 / Entra ID', google: 'Google Workspace', aws: 'Amazon Web Services',
|
||||
azure: 'Microsoft Azure', idp: 'Identity Provider', edr: 'EDR / XDR',
|
||||
siem: 'SIEM / SOC', ticketing: 'Ticketing (Jira/ServiceNow)'
|
||||
};
|
||||
let _connOrgId = null;
|
||||
|
||||
async function openConnectors(orgId, orgName) {
|
||||
_connOrgId = orgId;
|
||||
try {
|
||||
const res = await fetch(`/api/organizations/${orgId}/connectors`, {
|
||||
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; }
|
||||
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;">
|
||||
<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;">
|
||||
</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>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
showConnModal(orgName, `
|
||||
<p style="font-size:.82rem;color:var(--gray-600);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>'); }
|
||||
}
|
||||
|
||||
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="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>
|
||||
</div>${body}
|
||||
</div></div>`);
|
||||
}
|
||||
function closeConn() { const m = document.getElementById('conn-modal'); if (m) m.remove(); }
|
||||
|
||||
async function saveConn(type, btn) {
|
||||
const row = btn.closest('.conn-row');
|
||||
const payload = {
|
||||
type,
|
||||
enabled: row.querySelector('.c-en').checked,
|
||||
config: { tenant_id: row.querySelector('.c-tenant').value.trim(), client_id: row.querySelector('.c-client').value.trim() },
|
||||
secret_status: 'pending'
|
||||
};
|
||||
btn.disabled = true; btn.textContent = '...';
|
||||
try {
|
||||
const res = await fetch(`/api/organizations/${_connOrgId}/connectors`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${api.token}`, 'X-Organization-Id': _connOrgId, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const j = await res.json();
|
||||
btn.disabled = false; btn.textContent = 'Salva';
|
||||
if (j.success) {
|
||||
if (typeof showNotification === 'function') showNotification('Connettore salvato', 'success');
|
||||
if (j.data && j.data.cli_hint) alert('Connettore salvato.\n\nPer caricare il SEGRETO nel vault, l\'amministratore esegue:\n\n' + j.data.cli_hint);
|
||||
} else if (typeof showNotification === 'function') showNotification(j.message || 'Errore', 'error');
|
||||
} catch (e) {
|
||||
btn.disabled = false; btn.textContent = 'Salva';
|
||||
if (typeof showNotification === 'function') showNotification('Errore di connessione', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderEntityBadge(type, voluntary) {
|
||||
if (voluntary) {
|
||||
return `<span class="entity-badge voluntary">Volontaria</span>`;
|
||||
}
|
||||
if (type === 'essential') {
|
||||
return `<span class="entity-badge essential">Essenziale</span>`;
|
||||
}
|
||||
if (type === 'important') {
|
||||
return `<span class="entity-badge important">Importante</span>`;
|
||||
}
|
||||
return `<span class="entity-badge not_applicable">Non NIS2</span>`;
|
||||
}
|
||||
|
||||
// ── Load compliance score per org ────────────────────────────────
|
||||
async function loadOrgScore(orgId) {
|
||||
try {
|
||||
// Temporarily switch to this org for the API call
|
||||
const savedOrgId = api.orgId;
|
||||
api.orgId = orgId;
|
||||
const result = await api.getDashboardOverview();
|
||||
api.orgId = savedOrgId;
|
||||
|
||||
if (result.success && result.data) {
|
||||
const score = result.data.compliance_score || 0;
|
||||
const risks = result.data.open_risks || 0;
|
||||
const incidents = result.data.active_incidents || 0;
|
||||
|
||||
const scoreRow = document.getElementById('score-row-' + orgId);
|
||||
const scoreVal = document.getElementById('score-val-' + orgId);
|
||||
const scoreFill = document.getElementById('score-fill-' + orgId);
|
||||
const statsRow = document.getElementById('stats-row-' + orgId);
|
||||
const statRisks = document.getElementById('stat-risks-' + orgId);
|
||||
const statIncidents = document.getElementById('stat-incidents-' + orgId);
|
||||
|
||||
if (scoreRow) scoreRow.style.display = 'flex';
|
||||
if (scoreVal) {
|
||||
scoreVal.textContent = score + '%';
|
||||
scoreVal.style.color = getScoreColor(score);
|
||||
}
|
||||
if (scoreFill) {
|
||||
scoreFill.style.width = score + '%';
|
||||
scoreFill.style.background = getScoreColor(score);
|
||||
}
|
||||
if (statsRow) statsRow.style.display = 'flex';
|
||||
if (statRisks) statRisks.textContent = risks;
|
||||
if (statIncidents) statIncidents.textContent = incidents;
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore per-org errors
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manage company (switch org + go to dashboard) ─────────────────
|
||||
function manageCompany(orgId) {
|
||||
api.setOrganization(orgId);
|
||||
window.location.href = 'dashboard-bi.html';
|
||||
}
|
||||
|
||||
// ── Search / Filter ──────────────────────────────────────────────
|
||||
function filterCompanies(query) {
|
||||
const q = query.toLowerCase().trim();
|
||||
const cards = document.querySelectorAll('.company-card');
|
||||
let anyVisible = false;
|
||||
|
||||
cards.forEach(card => {
|
||||
const name = card.dataset.name || '';
|
||||
const visible = !q || name.includes(q);
|
||||
card.style.display = visible ? '' : 'none';
|
||||
if (visible) anyVisible = true;
|
||||
});
|
||||
|
||||
const emptyState = document.getElementById('companies-empty');
|
||||
if (emptyState) {
|
||||
emptyState.style.display = (!anyVisible && allCompanies.length > 0) ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sector labels ────────────────────────────────────────────────
|
||||
function getSectorLabel(sector) {
|
||||
const labels = {
|
||||
energy_electricity: 'Energia - Elettricità',
|
||||
energy_oil: 'Energia - Petrolio', energy_gas: 'Energia - Gas',
|
||||
energy_hydrogen: 'Energia - Idrogeno',
|
||||
transport_air: 'Trasporti - Aereo', transport_rail: 'Trasporti - Ferroviario',
|
||||
transport_water: 'Trasporti - Marittimo', transport_road: 'Trasporti - Stradale',
|
||||
banking: 'Banche', financial_markets: 'Mercati Finanziari',
|
||||
health: 'Sanità', drinking_water: 'Acqua Potabile',
|
||||
waste_water: 'Acque Reflue', digital_infrastructure: 'Infrastruttura Digitale',
|
||||
ict_service_management: 'Gestione ICT B2B', public_administration: 'PA',
|
||||
space: 'Spazio', postal_courier: 'Postale/Corrieri',
|
||||
waste_management: 'Gestione Rifiuti', chemicals: 'Chimica',
|
||||
food: 'Alimentare', manufacturing_medical: 'Dispositivi Medici',
|
||||
manufacturing_computers: 'Computer/Elettronica',
|
||||
manufacturing_electrical: 'App. Elettriche',
|
||||
manufacturing_machinery: 'Macchinari', manufacturing_vehicles: 'Autoveicoli',
|
||||
manufacturing_transport: 'Mezzi di Trasporto',
|
||||
digital_providers: 'Fornitori Digitali', research: 'Ricerca', other: 'Altro'
|
||||
};
|
||||
return labels[sector] || sector || 'Settore non specificato';
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────
|
||||
loadCompanies();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,698 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Cross-Analysis (BI) — 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">
|
||||
<style>
|
||||
:root {
|
||||
--nis2-cyan: #06B6D4;
|
||||
--nis2-cyan-light: #ecfeff;
|
||||
--l4-purple: #7c3aed;
|
||||
--l4-purple-bg: #f5f3ff;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* ── 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-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);
|
||||
}
|
||||
.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); }
|
||||
|
||||
/* ── 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%;
|
||||
}
|
||||
|
||||
/* ── 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; }
|
||||
|
||||
/* ── 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); }
|
||||
}
|
||||
|
||||
/* ── 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; }
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/* ── 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; }
|
||||
|
||||
/* ── 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; }
|
||||
</style>
|
||||
</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 & Admin
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-body">
|
||||
|
||||
<!-- Portfolio header -->
|
||||
<div class="portfolio-header" id="portfolio-header">
|
||||
<div>
|
||||
<div style="font-size:0.72rem;opacity:0.6;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Il tuo portfolio</div>
|
||||
<div style="font-size:1rem;font-weight:600;">Caricamento...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<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
|
||||
</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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB: CHAT -->
|
||||
<div class="tab-pane active" id="tab-chat" role="tabpanel">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="chat-input-area">
|
||||
<div class="quick-qs" id="quick-qs">
|
||||
<button class="quick-q" onclick="setQuestion('Quali sono i gap di compliance più comuni nel mio portfolio?')">Gap più frequenti</button>
|
||||
<button class="quick-q" onclick="setQuestion('Qual è il livello medio di rischio nelle mie organizzazioni?')">Livello di rischio medio</button>
|
||||
<button class="quick-q" onclick="setQuestion('Quali categorie NIS2 sono meno implementate?')">Categorie NIS2 deboli</button>
|
||||
<button class="quick-q" onclick="setQuestion('Quali organizzazioni hanno bisogno di intervento urgente?')">Intervento urgente</button>
|
||||
<button class="quick-q" onclick="setQuestion('Com\'è lo stato della formazione nei miei clienti?')">Stato formazione</button>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
Dati anonimizzati e aggregati — nessun nome organizzazione inviato all'AI
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/common.js"></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"></script>
|
||||
<script src="/js/i18n.js"></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();
|
||||
|
||||
let PORTFOLIO = null; // dati aggregati portfolio
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────
|
||||
loadPortfolio();
|
||||
|
||||
async function loadPortfolio() {
|
||||
try {
|
||||
const r = await api.get('/cross-analysis/portfolio');
|
||||
if (!r.success) {
|
||||
if (r.error_code === 'INSUFFICIENT_ROLE') {
|
||||
document.getElementById('portfolio-header').innerHTML = `
|
||||
<div style="color:white;text-align:center;width:100%;">
|
||||
${ico('it-lock','ico-lg')}<br>
|
||||
<strong>Funzione riservata a Consulenti e Super Admin</strong>
|
||||
</div>`;
|
||||
document.querySelector('.card').innerHTML = `
|
||||
<div style="padding:40px;text-align:center;color:var(--gray-500);">
|
||||
${ico('it-lock','ico-lg')}
|
||||
<p style="margin-top:12px;">Hai bisogno del ruolo <strong>Consultant</strong> o <strong>Super Admin</strong> per accedere all\'analisi cross-organizzazione.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
throw new Error(r.message);
|
||||
}
|
||||
PORTFOLIO = r.data;
|
||||
renderPortfolioHeader(PORTFOLIO);
|
||||
renderPortfolioTab(PORTFOLIO);
|
||||
} catch(e) {
|
||||
console.error('Portfolio load error:', e);
|
||||
document.getElementById('portfolio-header').innerHTML = `
|
||||
<div style="color:white;opacity:0.7;">Errore caricamento portfolio</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPortfolioHeader(d) {
|
||||
const agg = d.aggregated || {};
|
||||
const score = agg.avg_compliance_score ?? '—';
|
||||
const totalRisks = agg.risks?.total_open ?? '—';
|
||||
const trainPct = agg.training?.avg_completion_rate ?? '—';
|
||||
document.getElementById('portfolio-header').innerHTML = `
|
||||
<div>
|
||||
<div style="font-size:0.72rem;opacity:0.6;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Il tuo portfolio</div>
|
||||
<div style="font-size:1.1rem;font-weight:700;">${d.org_count} organizzazioni monitorate</div>
|
||||
</div>
|
||||
<div class="portfolio-stat"><div class="val">${score}${score!=='—'?'%':''}</div><div class="lbl">Compliance medio</div></div>
|
||||
<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) {
|
||||
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;">';
|
||||
|
||||
// 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">`;
|
||||
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;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.82rem;margin-bottom:3px;">
|
||||
<span>${sec}</span><span style="font-weight:600;">${cnt} (${pct}%)</span>
|
||||
</div>
|
||||
<div style="height:4px;background:var(--gray-200);border-radius:2px;">
|
||||
<div style="height:100%;width:${pct}%;background:var(--l4-purple);border-radius:2px;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// 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">`;
|
||||
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;
|
||||
html += `<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;font-size:0.82rem;">
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:${colors[range]||'var(--gray-400)'};flex-shrink:0;"></div>
|
||||
<span style="flex:1;">${range}</span>
|
||||
<strong>${cnt} org</strong>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// 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));">';
|
||||
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;">
|
||||
<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;">
|
||||
<div style="height:100%;width:${score}%;background:${color};border-radius:2px;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div></div></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
document.getElementById('portfolio-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Tab switching ─────────────────────────────────────────────────
|
||||
function switchTab(tab) {
|
||||
const tabs = ['chat','history','portfolio'];
|
||||
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
||||
const active = tabs[i] === tab;
|
||||
b.classList.toggle('active', active);
|
||||
b.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
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;
|
||||
|
||||
async function sendMessage() {
|
||||
if (isThinking) return;
|
||||
const input = document.getElementById('chat-input');
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
|
||||
// Rimuovi empty state
|
||||
const empty = document.getElementById('chat-empty');
|
||||
if (empty) empty.remove();
|
||||
|
||||
// Mostra messaggio utente
|
||||
appendMessage('user', question);
|
||||
input.value = '';
|
||||
autoResizeTextarea(input);
|
||||
|
||||
// Thinking bubble
|
||||
isThinking = true;
|
||||
document.getElementById('btn-send').disabled = true;
|
||||
const thinkId = 'think-' + Date.now();
|
||||
appendThinking(thinkId);
|
||||
|
||||
try {
|
||||
const r = await api.post('/cross-analysis/analyze', { question });
|
||||
removeThinking(thinkId);
|
||||
if (r.success && r.data?.result) {
|
||||
appendAIResponse(r.data.result, r.data.org_count);
|
||||
} else {
|
||||
appendError(r.message || 'Errore sconosciuto');
|
||||
}
|
||||
} catch(e) {
|
||||
removeThinking(thinkId);
|
||||
appendError('Errore di connessione: ' + e.message);
|
||||
} finally {
|
||||
isThinking = false;
|
||||
document.getElementById('btn-send').disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
msgs.innerHTML += `
|
||||
<div class="msg ${role}">
|
||||
<div class="msg-avatar">${ico(iconName)}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-bubble">${escapeHtml(text)}</div>
|
||||
<div class="msg-time">${time}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
|
||||
function appendAIResponse(result, orgCount) {
|
||||
const msgs = document.getElementById('chat-messages');
|
||||
const time = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit'});
|
||||
|
||||
let inner = `<div class="ai-answer">${escapeHtml(result.answer || 'Nessuna risposta')}</div>`;
|
||||
|
||||
if (result.key_findings?.length) {
|
||||
inner += `<div class="ai-section">
|
||||
<div class="ai-section-title">${ico('it-search')} Risultati chiave</div>
|
||||
<ul>${result.key_findings.map(f => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
|
||||
</div>`;
|
||||
}
|
||||
if (result.recommendations?.length) {
|
||||
inner += `<div class="ai-section">
|
||||
<div class="ai-section-title">${ico('it-note')} Raccomandazioni</div>
|
||||
<ul>${result.recommendations.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>
|
||||
</div>`;
|
||||
}
|
||||
if (result.risk_areas?.length) {
|
||||
inner += `<div class="ai-section">
|
||||
<div class="ai-section-title">${ico('it-warning-circle')} Aree di attenzione</div>
|
||||
<ul>${result.risk_areas.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>
|
||||
</div>`;
|
||||
}
|
||||
if (result.benchmark_note) {
|
||||
inner += `<div class="ai-benchmark">${ico('it-chart-line')} ${escapeHtml(result.benchmark_note)}</div>`;
|
||||
}
|
||||
if (result.privacy_note) {
|
||||
inner += `<div class="ai-privacy-note">${ico('it-lock')} ${escapeHtml(result.privacy_note)}</div>`;
|
||||
}
|
||||
|
||||
inner += `<div style="margin-top:8px;font-size:0.68rem;color:var(--gray-400);">Basato su ${orgCount} organizzazioni anonimizzate</div>`;
|
||||
|
||||
msgs.innerHTML += `
|
||||
<div class="msg ai">
|
||||
<div class="msg-avatar">${ico('it-software')}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-bubble">${inner}</div>
|
||||
<div class="msg-time">${time}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
|
||||
function appendError(msg) {
|
||||
const msgs = document.getElementById('chat-messages');
|
||||
msgs.innerHTML += `
|
||||
<div class="msg ai">
|
||||
<div class="msg-avatar" style="background:var(--danger);">${ico('it-close')}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-bubble" style="background:var(--danger-bg);color:var(--danger);">
|
||||
${ico('it-warning-circle')} ${escapeHtml(msg)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
|
||||
function appendThinking(id) {
|
||||
const msgs = document.getElementById('chat-messages');
|
||||
msgs.innerHTML += `
|
||||
<div class="msg ai" id="${id}">
|
||||
<div class="msg-avatar">${ico('it-software')}</div>
|
||||
<div class="msg-body">
|
||||
<div class="thinking-bubble">
|
||||
<div class="thinking-dot"></div>
|
||||
<div class="thinking-dot"></div>
|
||||
<div class="thinking-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
|
||||
function removeThinking(id) {
|
||||
document.getElementById(id)?.remove();
|
||||
}
|
||||
|
||||
// ── History ───────────────────────────────────────────────────────
|
||||
async function loadHistory() {
|
||||
const el = document.getElementById('history-content');
|
||||
el.innerHTML = '<div class="spinner" style="margin:30px auto;display:block;"></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; }
|
||||
const rows = r.data?.history || [];
|
||||
if (!rows.length) {
|
||||
el.innerHTML = '<p class="text-muted" style="padding:20px;text-align:center;">Nessuna analisi precedente.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = rows.map(row => `
|
||||
<div style="border-bottom:1px solid var(--gray-100);padding:12px 0;">
|
||||
<div style="font-size:0.78rem;color:var(--gray-400);margin-bottom:4px;">
|
||||
${new Date(row.created_at).toLocaleString('it-IT')}
|
||||
</div>
|
||||
<div style="font-size:0.84rem;font-weight:600;color:var(--gray-700);margin-bottom:4px;">
|
||||
${escapeHtml((row.prompt_summary || '').replace(/^CROSS_ORG \[.*?\]: /, ''))}
|
||||
</div>
|
||||
<div style="font-size:0.8rem;color:var(--gray-500);">${escapeHtml(row.response_summary || '—')}</div>
|
||||
</div>`).join('');
|
||||
} catch(e) {
|
||||
el.innerHTML = '<p class="text-muted">Errore: ' + escapeHtml(e.message) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
function setQuestion(q) {
|
||||
document.getElementById('chat-input').value = q;
|
||||
document.getElementById('chat-input').focus();
|
||||
autoResizeTextarea(document.getElementById('chat-input'));
|
||||
}
|
||||
|
||||
function autoResizeTextarea(el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
|
||||
}
|
||||
|
||||
document.getElementById('chat-input').addEventListener('input', function() {
|
||||
autoResizeTextarea(this);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Modello Organizzativo SGSI (ISO 27001) (BI) - 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">
|
||||
<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; }
|
||||
.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; }
|
||||
.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-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-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.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; }
|
||||
.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-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; }
|
||||
</style>
|
||||
</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">
|
||||
<span class="savehint" id="isms-savehint" role="status" aria-live="polite"></span>
|
||||
<button class="btn btn-outline btn-sm" onclick="ismsExportView()" data-i18n="isms.export">
|
||||
<svg class="icon icon-sm" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-file"></use></svg>
|
||||
<span>Esporta SGSI</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-body">
|
||||
<div class="isms-intro">
|
||||
<strong data-i18n="isms.intro.title">Costruisci il tuo Sistema di Gestione della Sicurezza delle Informazioni.</strong>
|
||||
<span data-i18n="isms.intro.body">Procedura guidata ISO/IEC 27001:2022 (clausole 4-10 + Statement of Applicability). Lo Statement of Applicability viene pre-popolato dalle tue risposte alla Gap Analysis NIS2. Attiva le estensioni cloud ISO 27017/27018 dove pertinenti.</span>
|
||||
</div>
|
||||
<div class="isms-disclaimer" data-i18n="isms.disclaimer">Strumento di supporto e pre-audit: non costituisce una certificazione ISO 27001 né un parere professionale vincolante. Gli obblighi normativi in Italia derivano da NIS2 / D.Lgs. 138/2024 / Determinazioni ACN; ISO 27001/27017/27018 sono best practice.</div>
|
||||
|
||||
<div class="isms-steps" id="isms-steps"></div>
|
||||
|
||||
<!-- STEP 1 - Contesto & Ambito (cl.4) -->
|
||||
<div class="isms-panel" id="panel-0">
|
||||
<div class="card"><div class="card-body">
|
||||
<h3 data-i18n="isms.s1.title">1. Contesto e Ambito (cl. 4)</h3>
|
||||
<div class="isms-field"><label for="f-scope" data-i18n="isms.s1.scope">Ambito del SGSI (scope statement)</label><textarea id="f-scope" rows="3" placeholder="Es. Tutti i sistemi e processi a supporto dei servizi ICT erogati dalla sede di..."></textarea></div>
|
||||
<div class="isms-field"><label for="f-ctx-int" data-i18n="isms.s1.ctx_int">Contesto interno</label><textarea id="f-ctx-int" rows="2"></textarea></div>
|
||||
<div class="isms-field"><label for="f-ctx-ext" data-i18n="isms.s1.ctx_ext">Contesto esterno</label><textarea id="f-ctx-ext" rows="2"></textarea></div>
|
||||
<div class="isms-field"><label for="f-parties" data-i18n="isms.s1.parties">Parti interessate (una per riga)</label><textarea id="f-parties" rows="3" placeholder="Clienti Autorità (ACN/CSIRT) Fornitori ICT"></textarea></div>
|
||||
<div class="isms-field"><label for="f-boundaries" data-i18n="isms.s1.boundaries">Confini del SGSI</label><textarea id="f-boundaries" rows="2"></textarea></div>
|
||||
<div class="isms-field"><label for="f-exclusions" data-i18n="isms.s1.exclusions">Esclusioni motivate</label><textarea id="f-exclusions" rows="2"></textarea></div>
|
||||
<div class="isms-flags">
|
||||
<strong data-i18n="isms.s1.cloud">Estensioni cloud (attivano i controlli condizionali nel SoA)</strong>
|
||||
<label><input type="checkbox" id="f-uses-cloud"> <span data-i18n="isms.s1.uses_cloud">Usiamo servizi cloud pubblici (Microsoft 365, AWS, Google...) → ISO 27017</span></label>
|
||||
<label><input type="checkbox" id="f-cloud-provider"> <span data-i18n="isms.s1.cloud_provider">Siamo fornitori di servizi cloud → ISO 27017 (lato provider)</span></label>
|
||||
<label><input type="checkbox" id="f-pii-cloud"> <span data-i18n="isms.s1.pii_cloud">Trattiamo dati personali (PII) in cloud pubblico come responsabili → ISO 27018</span></label>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveStep1()" data-i18n="isms.save_next">Salva e continua</button>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 2 - Leadership (cl.5) -->
|
||||
<div class="isms-panel" id="panel-1">
|
||||
<div class="card"><div class="card-body">
|
||||
<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;">
|
||||
<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" onclick="addRole()" data-i18n="isms.s2.add">Aggiungi ruolo</button>
|
||||
</div>
|
||||
<button class="btn btn-outline" 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">
|
||||
<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-bi.html">Rischi</a>: questo passo ne dichiara la metodologia.</p>
|
||||
<button class="btn btn-primary" onclick="saveStep3()" data-i18n="isms.save_next">Salva e continua</button>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 4 - SoA -->
|
||||
<div class="isms-panel" id="panel-3">
|
||||
<div class="card"><div class="card-body">
|
||||
<h3 data-i18n="isms.s4.title">4. Statement of Applicability</h3>
|
||||
<p class="text-muted" data-i18n="isms.s4.hint">I controlli sono pre-popolati dalle risposte NIS2 dove esiste corrispondenza (badge "da NIS2"). Conferma applicabilità, stato e motivazione per ciascuno.</p>
|
||||
<div style="margin:10px 0;">
|
||||
<button class="btn btn-sm btn-outline" 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" style="margin-top:14px;" onclick="goStep(4)" data-i18n="isms.next">Continua</button>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 5 - Documented information (cl.7-8) -->
|
||||
<div class="isms-panel" id="panel-4">
|
||||
<div class="card"><div class="card-body">
|
||||
<h3 data-i18n="isms.s5.title">5. Documented Information (cl. 7-8)</h3>
|
||||
<div class="raci-row" style="border:none;">
|
||||
<label for="d-type" class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);">Tipo di documento</label>
|
||||
<select id="d-type" style="flex:1 1 200px;" aria-label="Tipo di documento">
|
||||
<option value="policy_sgsi">Policy SGSI</option>
|
||||
<option value="dichiarazione_applicabilita">Dichiarazione di Applicabilità</option>
|
||||
<option value="politica_controllo_accessi">Politica controllo accessi</option>
|
||||
<option value="procedura_incident">Procedura gestione incidenti</option>
|
||||
<option value="procedura_continuita">Procedura continuità operativa</option>
|
||||
<option value="politica_fornitori">Politica sicurezza fornitori</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary" onclick="aiGenDoc()" id="btn-aigen" data-i18n="isms.s5.aigen">Genera bozza con AI</button>
|
||||
</div>
|
||||
<div class="savehint" id="aigen-hint" role="status" aria-live="polite"></div>
|
||||
<div id="docs-list" style="margin-top:14px;"></div>
|
||||
<button class="btn btn-outline" style="margin-top:14px;" onclick="goStep(5)" data-i18n="isms.next">Continua</button>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 6 - Monitoring & Readiness (cl.9-10) -->
|
||||
<div class="isms-panel" id="panel-5">
|
||||
<div class="card"><div class="card-body">
|
||||
<h3 data-i18n="isms.s6.title">6. Monitoraggio e Miglioramento (cl. 9-10)</h3>
|
||||
<p class="text-muted" data-i18n="isms.s6.hint">Gli audit interni e le non conformità si gestiscono nei moduli esistenti: <a href="reports-bi.html">Audit & Report</a> e NCR/CAPA.</p>
|
||||
<h4 data-i18n="isms.s6.readiness">Completamento del SGSI</h4>
|
||||
<div style="text-align:center; margin:14px 0;">
|
||||
<div style="font-size:2.6rem; font-weight:800;" id="readiness-pct">0%</div>
|
||||
</div>
|
||||
<div id="readiness-checklist"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/common.js"></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"></script>
|
||||
<script src="/js/i18n.js"></script>
|
||||
<script src="/js/help.js"></script>
|
||||
<script src="/js/isms.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+89
-2
@@ -279,7 +279,7 @@ const HelpSystem = (function () {
|
||||
items: [
|
||||
'<strong>Early Warning (24 ore)</strong> - preallarme al CSIRT/autorita\' competente entro 24 ore dalla conoscenza dell\'incidente significativo.',
|
||||
'<strong>Notifica (72 ore)</strong> - notifica completa entro 72 ore con valutazione iniziale, gravita\', impatto e indicatori di compromissione.',
|
||||
'<strong>Report Finale (30 giorni)</strong> - relazione finale dettagliata entro un mese dalla notifica, con descrizione, causa, misure adottate e impatto transfrontaliero.',
|
||||
'<strong>Report Finale (un mese)</strong> - relazione finale dettagliata entro un mese dalla notifica, con descrizione, causa, misure adottate e impatto transfrontaliero.',
|
||||
'La piattaforma traccia automaticamente le scadenze e invia promemoria per ciascuna fase.'
|
||||
]
|
||||
},
|
||||
@@ -940,6 +940,89 @@ const HelpSystem = (function () {
|
||||
'Architettura multi-tenant: ogni organizzazione ha dati isolati',
|
||||
'Ruolo consulente con accesso a più organizzazioni'
|
||||
]
|
||||
},
|
||||
|
||||
// ─── AI Cross-Analysis (portfolio consulente) ────────────────
|
||||
'cross-analysis': {
|
||||
title: 'Guida - AI Cross-Analysis Portfolio',
|
||||
intro: 'La Cross-Analysis è uno strumento riservato a Consulenti e Super Admin: aggrega in forma anonimizzata i dati di compliance di tutte le organizzazioni del portfolio per individuare pattern comuni, gap ricorrenti e benchmark. Serve a orientare le priorità di intervento, NON sostituisce l\'analisi puntuale di ciascuna organizzazione né costituisce parere legale.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'A chi è riservata',
|
||||
items: [
|
||||
'L\'accesso richiede il ruolo <strong>Consultant</strong> oppure <strong>Super Admin</strong>: gli altri utenti vedono un messaggio di accesso negato.',
|
||||
'I dati delle organizzazioni vengono mostrati in forma <strong>aggregata e anonimizzata</strong>, coerentemente con il principio di minimizzazione del trattamento.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Metriche di portfolio',
|
||||
items: [
|
||||
'<strong>Compliance medio</strong>, <strong>Rischi aperti</strong>, <strong>Formazione media</strong> e numero di <strong>Organizzazioni</strong>: una sintesi quantitativa del portfolio gestito.',
|
||||
'<strong>Distribuzione settoriale</strong> e <strong>distribuzione degli score di compliance</strong>: aiutano a capire dove si concentra il lavoro.',
|
||||
'Lo <strong>Score medio per categoria NIS2</strong> (dal più basso) evidenzia le famiglie di misure dell\'Art. 21.2 più deboli trasversalmente ai clienti.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Analisi AI del portfolio',
|
||||
items: [
|
||||
'L\'<strong>analisi AI</strong> elabora i dati aggregati per indicare i <strong>gap più frequenti</strong>, il livello di rischio medio, le categorie NIS2 deboli e dove serve un <strong>intervento urgente</strong>.',
|
||||
'Il <strong>benchmark NIS2</strong> confronta le organizzazioni tra loro su base anonimizzata, per posizionare ciascun cliente rispetto al portfolio.',
|
||||
'Le analisi precedenti sono consultabili nello storico, così da osservare l\'evoluzione nel tempo.',
|
||||
'<em>Nota interpretativa:</em> i suggerimenti AI sono un supporto decisionale; la valutazione di conformità e le scelte di perimetro restano responsabilità documentata del consulente e dell\'organizzazione.'
|
||||
]
|
||||
}
|
||||
],
|
||||
references: [
|
||||
'Direttiva (UE) 2022/2555 - Art. 21.2 (a-j) - le 10 famiglie di misure usate come dimensioni di benchmark',
|
||||
'Direttiva (UE) 2022/2555 - Art. 20 - governance: la vista di portfolio supporta il monitoraggio del consulente',
|
||||
'D.Lgs. 4 settembre 2024, n. 138 - recepimento NIS2 in Italia',
|
||||
'Architettura multi-tenant: aggregazione anonimizzata, dati delle singole organizzazioni mai mescolati',
|
||||
'Lo strumento è di supporto e benchmark, non sostituisce l\'autovalutazione ACN né un parere legale.'
|
||||
]
|
||||
},
|
||||
|
||||
// ─── Standard Continuità di Servizio (operativo) ─────────────
|
||||
'service-continuity': {
|
||||
title: 'Guida - Standard di Continuità del Servizio',
|
||||
intro: 'Questa pagina documenta lo standard operativo con cui la piattaforma NIS2 Agile garantisce la continuità del proprio servizio (architettura, healthcheck, ripristino, manutenzione, rate limiting). È una pagina tecnico-informativa sull\'affidabilità del prodotto SaaS; NON descrive gli obblighi di continuità operativa della TUA organizzazione, che si gestiscono nei moduli Rischi, Asset e nel Modello Organizzativo SGSI.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Architettura e healthcheck',
|
||||
items: [
|
||||
'I servizi (applicazione PHP+MySQL+Qdrant, database, vector DB) espongono endpoint di <strong>healthcheck</strong> monitorati: un\'anomalia genera un alert prima che l\'utente la percepisca.',
|
||||
'Il <strong>deploy a caldo</strong> applica gli aggiornamenti senza interruzione percepibile del servizio.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Resilienza e degradazione graceful',
|
||||
items: [
|
||||
'Le chiamate critiche adottano <strong>retry con backoff esponenziale</strong> e riconnessione automatica per assorbire indisponibilità transitorie.',
|
||||
'In caso di componente non disponibile, la piattaforma applica una <strong>degradazione graceful</strong>: il frontend mostra un fallback invece di bloccarsi.',
|
||||
'Sono definiti tempi di recovery attesi per ciascun servizio.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Manutenzione automatica',
|
||||
items: [
|
||||
'Job pianificati garantiscono igiene operativa: <strong>monitoraggio disco</strong> (alert se > 85%), <strong>cleanup</strong> periodico, <strong>rinnovo automatico dei certificati SSL</strong> (Let\'s Encrypt).',
|
||||
'Il <strong>rate limiting</strong> (per IP, per chiave/utente, per email, sui webhook) protegge la disponibilità del servizio da abusi e picchi.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Relazione con la TUA continuità operativa',
|
||||
items: [
|
||||
'Questa pagina riguarda l\'affidabilità del fornitore (NIS2 Agile). I tuoi obblighi di <strong>continuità operativa e gestione dei backup</strong> ai sensi della NIS2 vanno valutati e documentati nei tuoi moduli (Rischi, Asset, Audit & Report, SGSI).',
|
||||
'Puoi usare le informazioni qui presenti come elemento nella valutazione del rischio del fornitore (supply chain) NIS2 Agile.'
|
||||
]
|
||||
}
|
||||
],
|
||||
references: [
|
||||
'Direttiva (UE) 2022/2555 - Art. 21.2 (c) - continuità operativa, gestione dei backup, ripristino in caso di disastro e gestione delle crisi',
|
||||
'Direttiva (UE) 2022/2555 - Art. 21.2 (d) - sicurezza della catena di approvvigionamento (valutazione del fornitore SaaS)',
|
||||
'D.Lgs. 4 settembre 2024, n. 138 - Art. 24 - misure di gestione del rischio',
|
||||
'Determinazione ACN n. 164179 del 14/04/2025 - requisiti funzione Ripristino (RC) [da verificare il mapping puntuale con gli SLA del fornitore]',
|
||||
'NOTA: pagina informativa sull\'affidabilità del prodotto; gli obblighi NIS2 di continuità restano in capo all\'organizzazione utente.'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -983,7 +1066,11 @@ const HelpSystem = (function () {
|
||||
'kb.html': 'kb',
|
||||
'kb': 'kb',
|
||||
'companies.html': 'companies',
|
||||
'companies': 'companies'
|
||||
'companies': 'companies',
|
||||
'cross-analysis.html': 'cross-analysis',
|
||||
'cross-analysis': 'cross-analysis',
|
||||
'service-continuity.html': 'service-continuity',
|
||||
'service-continuity': 'service-continuity'
|
||||
};
|
||||
|
||||
// ── Stili CSS (iniettati una sola volta) ─────────────────────────
|
||||
|
||||
+1
-1
@@ -136,7 +136,7 @@ const I18n = (function () {
|
||||
'incidents.new': { it: 'Nuovo Incidente', en: 'New Incident' },
|
||||
'incidents.early_warning': { it: 'Early Warning (24h)', en: 'Early Warning (24h)' },
|
||||
'incidents.notification': { it: 'Notifica (72h)', en: 'Notification (72h)' },
|
||||
'incidents.final_report': { it: 'Report Finale (30gg)', en: 'Final Report (30d)' },
|
||||
'incidents.final_report': { it: 'Report Finale (1 mese)', en: 'Final Report (1 month)' },
|
||||
'incidents.timeline': { it: 'Cronologia', en: 'Timeline' },
|
||||
'incidents.severity': { it: 'Gravita\'', en: 'Severity' },
|
||||
'incidents.low': { it: 'Bassa', en: 'Low' },
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Knowledge Base (BI) - 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">
|
||||
<style>
|
||||
.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-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; }
|
||||
.kb-scope-badge { font-size: .72rem; padding: 2px 8px; border-radius: 10px; font-weight: 600; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-body">
|
||||
<p class="text-muted" style="margin-top:0;">
|
||||
Carica documenti (procedure, policy, normative, training material) che l'AI usera' per
|
||||
rispondere alle domande con citazioni precise. La visibilita' segue il modello a 3 livelli:
|
||||
<strong>SYSTEM</strong> (vendor), <strong>FIRM</strong> (studio + condivisioni esplicite),
|
||||
<strong>ORG</strong> (singola organizzazione cliente).
|
||||
</p>
|
||||
|
||||
<!-- Form upload (hidden by default) -->
|
||||
<div class="card" 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">
|
||||
<label for="kb-title">Titolo</label>
|
||||
<input type="text" id="kb-title" class="form-control" placeholder="Es: Procedura interna gestione incidenti NIS2">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="kb-entity-type">Tipo</label>
|
||||
<select id="kb-entity-type" class="form-control">
|
||||
<option value="custom">Procedura interna</option>
|
||||
<option value="platform_guide">Linea guida</option>
|
||||
<option value="csrd">Normativa</option>
|
||||
<option value="training">Training</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top:12px;">
|
||||
<label for="kb-text">Contenuto (testo libero, 50–50.000 caratteri)</label>
|
||||
<textarea id="kb-text" rows="8" class="form-control"
|
||||
placeholder="Incolla qui il testo della procedura, della checklist o del template..."></textarea>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px;border-top:1px solid var(--gray-100);padding-top:12px;">
|
||||
<label style="font-weight:600;font-size:.85rem;">Visibilita'</label>
|
||||
<div class="kb-scope-radios" style="margin-top:6px;">
|
||||
<label data-scope-opt="SYSTEM" style="display:none;">
|
||||
<input type="radio" name="kb-scope" value="SYSTEM"> Sistema (super_admin)
|
||||
</label>
|
||||
<label data-scope-opt="FIRM" style="display:none;">
|
||||
<input type="radio" name="kb-scope" value="FIRM"> Studio + condivisioni
|
||||
</label>
|
||||
<label data-scope-opt="ORG">
|
||||
<input type="radio" name="kb-scope" value="ORG" checked> Solo organizzazione corrente
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="kb-share-block" class="kb-share-block" style="display:none;">
|
||||
<label for="kb-shared-with" style="font-size:.78rem;color:var(--gray-600);">
|
||||
Organizzazioni clienti destinatarie (Ctrl/Cmd per selezione multipla, opzionale):
|
||||
</label>
|
||||
<select id="kb-shared-with" multiple size="5" class="form-control">
|
||||
<option value="">Caricamento organizzazioni del firm...</option>
|
||||
</select>
|
||||
<div style="font-size:.72rem;color:var(--gray-500);margin-top:4px;">
|
||||
Lascia vuoto per condividere solo con i collaboratori dello studio.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px;display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn btn-primary" id="btn-kb-submit">Indicizza documento</button>
|
||||
<button class="btn btn-secondary" id="btn-kb-cancel">Annulla</button>
|
||||
<span id="kb-status" role="status" aria-live="polite" style="margin-left:8px;font-size:.85rem;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista documenti -->
|
||||
<div class="card kb-doc-list">
|
||||
<div class="card-header">
|
||||
<h3>Documenti caricati visibili</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="kb-doc-list">
|
||||
<div class="text-muted">Caricamento...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search preview -->
|
||||
<div class="card" 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>
|
||||
<input type="text" id="kb-search-query" class="form-control" placeholder="Es: come gestire un incidente NIS2?" aria-label="Query di ricerca semantica">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<div id="kb-search-results" style="margin-top:12px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Stessa logica JS della pagina live (parita' funzionale assoluta, zero backend). -->
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/api.js"></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"></script>
|
||||
<script src="/js/i18n.js"></script>
|
||||
<script src="/js/help.js"></script>
|
||||
<script src="/js/kb.js"></script>
|
||||
<script>
|
||||
// Gate auth + chrome come le altre pagine -bi.
|
||||
if (typeof checkAuth === 'function' && !checkAuth()) {
|
||||
// checkAuth reindirizza a login se non autenticato
|
||||
} else {
|
||||
if (typeof loadSidebar === 'function') loadSidebar();
|
||||
if (typeof I18n !== 'undefined' && I18n.init) I18n.init();
|
||||
if (typeof HelpSystem !== 'undefined' && HelpSystem.init) HelpSystem.init();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,273 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Aggiornamenti Normativi (BI) - 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">
|
||||
<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; }
|
||||
</style>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid mb-24" id="stats-grid">
|
||||
<div class="stat-card"><div class="spinner"></div></div>
|
||||
</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>
|
||||
|
||||
<!-- 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">
|
||||
<option value="">Tutte le fonti</option>
|
||||
<option value="dlgs_138_2024">D.Lgs. 138/2024</option>
|
||||
<option value="nis2_directive">Direttiva NIS2</option>
|
||||
<option value="acn_guideline">ACN Guideline</option>
|
||||
<option value="dora">DORA</option>
|
||||
<option value="enisa">ENISA</option>
|
||||
<option value="iso27001">ISO 27001</option>
|
||||
</select>
|
||||
<label for="filter-impact" class="visually-hidden">Filtra per livello di impatto</label>
|
||||
<select id="filter-impact" class="form-control" style="width:auto;" onchange="loadUpdates()" aria-label="Filtra per livello di impatto">
|
||||
<option value="">Tutti i livelli</option>
|
||||
<option value="critical">Critico</option>
|
||||
<option value="high">Alto</option>
|
||||
<option value="medium">Medio</option>
|
||||
<option value="low">Basso</option>
|
||||
<option value="informational">Informativo</option>
|
||||
</select>
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:0.875rem; cursor:pointer;">
|
||||
<input type="checkbox" id="filter-action" onchange="loadUpdates()" style="accent-color:var(--primary);">
|
||||
Solo azione richiesta
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="updates-container"><div class="spinner" style="margin:60px auto;"></div></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/common.js"></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"></script>
|
||||
<script src="/js/i18n.js"></script>
|
||||
<script src="/js/help.js"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
I18n.init();
|
||||
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
|
||||
|
||||
const sourceLabels = {
|
||||
nis2_directive: 'NIS2 Directive', dlgs_138_2024: 'D.Lgs. 138/2024',
|
||||
acn_guideline: 'ACN', dora: 'DORA', enisa: 'ENISA', iso27001: 'ISO 27001', other: 'Altro'
|
||||
};
|
||||
const impactLabels = {
|
||||
critical: 'Critico', high: 'Alto', medium: 'Medio', low: 'Basso', informational: 'Informativo'
|
||||
};
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const result = await api.request('GET', '/normative/stats');
|
||||
if (result.success) renderStats(result.data);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
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>`;
|
||||
|
||||
document.getElementById('ack-pct').textContent = (d.ack_rate || 0) + '%';
|
||||
document.getElementById('ack-bar').style.width = (d.ack_rate || 0) + '%';
|
||||
}
|
||||
|
||||
async function loadUpdates() {
|
||||
const container = document.getElementById('updates-container');
|
||||
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
const source = document.getElementById('filter-source').value;
|
||||
const impact = document.getElementById('filter-impact').value;
|
||||
const action = document.getElementById('filter-action').checked;
|
||||
if (source) params.append('source', source);
|
||||
if (impact) params.append('impact', impact);
|
||||
if (action) params.append('action_required', '1');
|
||||
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPending() {
|
||||
const container = document.getElementById('updates-container');
|
||||
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></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>'; }
|
||||
}
|
||||
|
||||
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>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
updates.forEach(u => {
|
||||
const acked = u.is_acknowledged || !!(u.acknowledged_at);
|
||||
const actionClass = u.action_required ? 'action-required' : '';
|
||||
const ackedClass = acked ? 'acked' : '';
|
||||
const domains = (u.affected_domains || []).map(d =>
|
||||
`<span class="source-chip">${escapeHtml(d)}</span>`
|
||||
).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;">
|
||||
<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>' : ''}
|
||||
${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-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>
|
||||
${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>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function acknowledgeUpdate(id) {
|
||||
showModal('Presa Visione', `
|
||||
<p style="font-size:0.875rem; color:var(--gray-600); 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>`);
|
||||
}
|
||||
|
||||
async function confirmAck(id) {
|
||||
const notes = document.getElementById('ack-notes').value;
|
||||
try {
|
||||
const result = await api.request('POST', `/normative/${id}/ack`, { notes });
|
||||
if (result.success) {
|
||||
closeModal();
|
||||
showNotification('Presa visione registrata e documentata.', 'success');
|
||||
loadUpdates();
|
||||
loadStats();
|
||||
} else showNotification(result.message || 'Errore.', 'error');
|
||||
} catch (e) { showNotification('Errore di connessione.', 'error'); }
|
||||
}
|
||||
|
||||
// Init
|
||||
loadStats();
|
||||
loadUpdates();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,234 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Standard Continuita di Servizio (BI) — NIS2 Agile</title>
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Titillium Web','Segoe UI',-apple-system,sans-serif;font-size:14.5px;line-height:1.7;color:#1A1A2E;background:#F5F7FA}
|
||||
.icon-inline{vertical-align:-0.15em;margin-right:6px}
|
||||
.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}
|
||||
.page{max-width:960px;margin:0 auto;background:#fff;box-shadow:0 4px 40px rgba(0,0,0,.08)}
|
||||
.doc-header{background:linear-gradient(135deg,#0D1B2A 0%,#06c 100%);color:#fff;padding:40px 48px}
|
||||
.header-top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:22px}
|
||||
.brand{display:flex;align-items:center;gap:10px}
|
||||
.brand-icon{width:44px;height:44px;border-radius:10px;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.85)}
|
||||
.brand-icon .icon{fill:currentColor}
|
||||
.brand-name{font-size:1rem;font-weight:800}
|
||||
.brand-sub{font-size:.68rem;opacity:.6}
|
||||
.doc-meta{text-align:right;font-size:.75rem;opacity:.6}
|
||||
.doc-meta strong{display:block;color:rgba(255,255,255,.9);opacity:1;font-size:.85rem}
|
||||
.doc-title{font-size:1.65rem;font-weight:800;letter-spacing:-.02em;line-height:1.2;margin-bottom:6px}
|
||||
.doc-title span{color:rgba(255,255,255,.85)}
|
||||
.doc-sub{font-size:.84rem;opacity:.7}
|
||||
.tag-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:18px}
|
||||
.tag{display:inline-flex;align-items:center;gap:5px;padding:4px 12px;border-radius:20px;font-size:.7rem;font-weight:600;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);color:#fff}
|
||||
.tag .icon{fill:currentColor}
|
||||
.content{padding:36px 48px}
|
||||
h2{font-size:1.25rem;font-weight:800;color:#06c;margin:36px 0 14px;padding-bottom:6px;border-bottom:2px solid #eee;display:flex;align-items:center;gap:8px}
|
||||
h2:first-child{margin-top:0}
|
||||
h2 .icon{fill:currentColor;flex:none}
|
||||
h3{font-size:1rem;font-weight:700;color:#06c;margin:22px 0 8px}
|
||||
p{margin:8px 0}ul,ol{margin:8px 0 8px 20px}li{margin:4px 0}
|
||||
.info-box{background:#E8F5E9;border-left:4px solid #2E7D32;padding:14px 18px;border-radius:0 8px 8px 0;margin:16px 0;font-size:.88rem;display:flex;gap:8px;align-items:flex-start}
|
||||
.info-box .icon{fill:#2E7D32;flex:none;margin-top:2px}
|
||||
.warn-box{background:#FFF3E0;border-left:4px solid #F57F17;padding:14px 18px;border-radius:0 8px 8px 0;margin:16px 0;font-size:.88rem;display:flex;gap:8px;align-items:flex-start}
|
||||
.warn-box .icon{fill:#F57F17;flex:none;margin-top:2px}
|
||||
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:.82rem}
|
||||
th{background:#f5f5f5;padding:10px 12px;text-align:left;font-weight:700;border-bottom:2px solid #ddd;font-size:.75rem;text-transform:uppercase;letter-spacing:.5px;color:#333}
|
||||
td{padding:9px 12px;border-bottom:1px solid #F0F2F5;vertical-align:top}
|
||||
tr:hover td{background:#FAFBFC}
|
||||
code{background:#f5f5f5;padding:2px 6px;border-radius:4px;font-size:.8rem;font-family:'Roboto Mono','Courier New',monospace;color:#333}
|
||||
pre{background:#1A1A2E;color:#E0E4E8;padding:16px 20px;border-radius:8px;overflow-x:auto;font-size:.78rem;line-height:1.5;margin:12px 0}
|
||||
.layer{display:grid;grid-template-columns:60px 1fr;gap:0;margin:16px 0}
|
||||
.layer-num{background:#06c;color:#fff;font-weight:800;font-size:1.1rem;display:flex;align-items:center;justify-content:center;border-radius:8px 0 0 8px}
|
||||
.layer-body{background:#f9f9f9;padding:16px 20px;border-radius:0 8px 8px 0;border:1px solid #eee;border-left:none}
|
||||
.layer-body h4{margin:0 0 6px;color:#06c;font-size:.95rem;display:flex;align-items:center;gap:6px}
|
||||
.layer-body h4 .icon{fill:currentColor;flex:none}
|
||||
.badge-ok{display:inline-block;background:#2E7D32;color:#fff;padding:2px 8px;border-radius:10px;font-size:.68rem;font-weight:700}
|
||||
.badge-warn{display:inline-block;background:#F57F17;color:#fff;padding:2px 8px;border-radius:10px;font-size:.68rem;font-weight:700}
|
||||
.badge-crit{display:inline-block;background:#C62828;color:#fff;padding:2px 8px;border-radius:10px;font-size:.68rem;font-weight:700}
|
||||
.doc-footer{background:#f5f5f5;padding:24px 48px;border-top:1px solid #ddd;font-size:.78rem;color:#5F6B7A;display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px}
|
||||
a{color:#06c}
|
||||
:focus-visible{outline:3px solid #06c;outline-offset:2px}
|
||||
@media print{body{background:#fff}.page{box-shadow:none}}
|
||||
@media(max-width:700px){.content,.doc-header,.doc-footer{padding:20px}.layer{grid-template-columns:1fr}.layer-num{border-radius:8px 8px 0 0;padding:8px}.layer-body{border-radius:0 0 8px 8px;border-left:1px solid #eee}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="doc-header">
|
||||
<div class="header-top">
|
||||
<div class="brand">
|
||||
<div class="brand-icon"><svg class="icon icon-white" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-locked"></use></svg></div>
|
||||
<div><div class="brand-name">NIS2 Agile</div><div class="brand-sub">Cybersecurity Compliance NIS2</div></div>
|
||||
</div>
|
||||
<div class="doc-meta"><strong>Standard Operativo</strong>Continuita di Servizio</div>
|
||||
</div>
|
||||
<h1 class="doc-title">Strategia di <span>Continuita di Servizio</span></h1>
|
||||
<div class="doc-sub">Architettura di resilienza, monitoraggio, auto-recovery e protezione dei dati</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag"><svg class="icon icon-xs icon-white" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-calendar"></use></svg> Aprile 2026</span>
|
||||
<span class="tag"><svg class="icon icon-xs icon-white" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-software"></use></svg> PHP+MySQL+Qdrant</span>
|
||||
<span class="tag"><svg class="icon icon-xs icon-white" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg> Hetzner CPX31</span>
|
||||
<span class="tag"><svg class="icon icon-xs icon-white" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-chart-line"></use></svg> 99.9% SLA Target</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="content">
|
||||
|
||||
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-pa"></use></svg> 1. Architettura di Servizio</h2>
|
||||
<p><strong>NIS2 Agile</strong> e composto dai seguenti servizi gestiti sulla piattaforma Hetzner condivisa con gli altri prodotti della suite Agile Technology.</p>
|
||||
<table><caption class="sr-only">Servizi, stack tecnologico e healthcheck</caption><thead><tr><th scope="col">Servizio</th><th scope="col">Stack</th><th scope="col">Healthcheck</th></tr></thead><tbody><tr><td><code>nis2-app</code></td><td>PHP+MySQL+Qdrant</td><td><span class="badge-ok">/health</span></td></tr><tr><td><code>MySQL/PostgreSQL</code></td><td>Database condiviso</td><td><span class="badge-ok">ping</span></td></tr><tr><td><code>Qdrant</code></td><td>Vector DB (embeddings)</td><td><span class="badge-ok">HTTP 200</span></td></tr><tr><td><code>AgileHub (NEXUS)</code></td><td>Ticket + AI + Notifiche</td><td><span class="badge-ok">/health</span></td></tr></tbody></table>
|
||||
|
||||
<div class="info-box">
|
||||
<svg class="icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-info-circle"></use></svg>
|
||||
<div><strong>Deploy a caldo</strong>: le modifiche al codice sorgente vengono applicate via bind mount Docker o PM2 reload. Nessun rebuild necessario per aggiornamenti ordinari.</div>
|
||||
</div>
|
||||
|
||||
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-files"></use></svg> 2. Strategia di Resilienza a 7 Livelli</h2>
|
||||
|
||||
<div class="layer"><div class="layer-num">L1</div><div class="layer-body">
|
||||
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-refresh"></use></svg> Auto-Restart Container / PM2</h4>
|
||||
<p>Ogni servizio ha <code>restart: unless-stopped</code> (Docker) o <code>pm2 --watch</code> (Node.js). Se un processo crasha, viene riavviato automaticamente entro 10-30 secondi.</p>
|
||||
<p><strong>Tempo di recovery</strong>: < 30 secondi</p>
|
||||
</div></div>
|
||||
|
||||
<div class="layer"><div class="layer-num">L2</div><div class="layer-body">
|
||||
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-chart-line"></use></svg> Healthcheck Nativo</h4>
|
||||
<p>Ogni servizio espone un endpoint <code>/health</code> o <code>/api/health</code> verificato ogni 30 secondi. Se il check fallisce 3 volte consecutive, il container/processo viene ricreato automaticamente.</p>
|
||||
<p><strong>Rileva</strong>: crash applicativo, memoria esaurita, deadlock, loop infinito.</p>
|
||||
</div></div>
|
||||
|
||||
<div class="layer"><div class="layer-num">L3</div><div class="layer-body">
|
||||
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-inbox"></use></svg> Database Retry con Reconnect</h4>
|
||||
<p>Tutti i servizi implementano retry automatico su errori transitori del database (MySQL/PostgreSQL):</p>
|
||||
<ul>
|
||||
<li><strong>2006</strong> — Server has gone away (reconnect)</li>
|
||||
<li><strong>2013</strong> — Lost connection during query (retry)</li>
|
||||
<li><strong>1213</strong> — Deadlock found (retry con backoff)</li>
|
||||
</ul>
|
||||
<p>Strategia: 2 retry con backoff esponenziale + reconnect automatico.</p>
|
||||
</div></div>
|
||||
|
||||
<div class="layer"><div class="layer-num">L4</div><div class="layer-body">
|
||||
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-exchange-circle"></use></svg> Resilienza API Esterne</h4>
|
||||
<p>Le chiamate ad API esterne (Claude AI, ElevenLabs TTS, Voyage embeddings) hanno protezione a 3 livelli:</p>
|
||||
<ol>
|
||||
<li><strong>Retry con backoff</strong>: 3 tentativi su errori transitori (429, 500, 502, 503)</li>
|
||||
<li><strong>Degradazione graceful</strong>: se l'AI non risponde, il prodotto funziona con i dati locali</li>
|
||||
<li><strong>Frontend fallback</strong>: messaggio "AI temporaneamente non disponibile" con suggerimenti alternativi</li>
|
||||
</ol>
|
||||
</div></div>
|
||||
|
||||
<div class="layer"><div class="layer-num">L5</div><div class="layer-body">
|
||||
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-horn"></use></svg> Error Alerting in Tempo Reale</h4>
|
||||
<p>Ogni errore critico genera una notifica AgileHub (campanella) per l'admin e, se configurato, un'email di alert. L'admin vede l'errore entro 30 secondi via polling badge.</p>
|
||||
</div></div>
|
||||
|
||||
<div class="layer"><div class="layer-num">L6</div><div class="layer-body">
|
||||
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-password-visible"></use></svg> Monitoraggio Esterno AgileHub</h4>
|
||||
<p>Il cron AgileHub (<code>ticket-agent-cron.sh</code>) verifica lo stato di tutti i container ogni 2 minuti. Se un prodotto non e raggiungibile, viene segnalato nel log e inviata notifica.</p>
|
||||
<p>Complementare ai healthcheck interni: rileva problemi di rete, proxy Apache, DNS.</p>
|
||||
</div></div>
|
||||
|
||||
<div class="layer"><div class="layer-num">L7</div><div class="layer-body">
|
||||
<h4><svg class="icon icon-sm icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-tool"></use></svg> Manutenzione Preventiva</h4>
|
||||
<p>Script automatici prevengono il degrado:</p>
|
||||
<table>
|
||||
<caption class="sr-only">Operazioni di manutenzione preventiva</caption>
|
||||
<thead><tr><th scope="col">Operazione</th><th scope="col">Frequenza</th><th scope="col">Funzione</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Disk monitor</td><td>Ogni 6 ore</td><td>Alert email se disco > 85%</td></tr>
|
||||
<tr><td>Cleanup</td><td>Settimanale</td><td>Elimina file temporanei, Docker prune</td></tr>
|
||||
<tr><td>SSL renewal</td><td>Settimanale</td><td>Rinnovo automatico Let's Encrypt</td></tr>
|
||||
<tr><td>Claude auth</td><td>Orario</td><td>Refresh token OAuth Claude Code</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
|
||||
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg> 3. Protezione da Abuso</h2>
|
||||
<h3>Rate Limiting</h3>
|
||||
<table>
|
||||
<caption class="sr-only">Limiti di rate limiting per endpoint</caption>
|
||||
<thead><tr><th scope="col">Endpoint</th><th scope="col">Limite</th><th scope="col">Finestra</th><th scope="col">Tipo</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Login</td><td>5 tentativi</td><td>15 min</td><td>Per IP + lockout</td></tr>
|
||||
<tr><td>API generiche</td><td>100 req</td><td>1 ora</td><td>Per chiave/utente</td></tr>
|
||||
<tr><td>Password reset</td><td>3 req</td><td>1 ora</td><td>Per email</td></tr>
|
||||
<tr><td>Webhook esterni</td><td>50 req</td><td>1 min</td><td>Per IP</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-tool"></use></svg> 4. Modalita Manutenzione</h2>
|
||||
<p>Quando il sistema AgileHub applica un fix approvato, il <strong>maintenance mode</strong> si attiva automaticamente:</p>
|
||||
<ol>
|
||||
<li>Il cron agent crea il flag <code>/tmp/maintenance-NIS2.flag</code></li>
|
||||
<li>Apache rileva il flag e mostra la pagina di manutenzione a tutti gli utenti</li>
|
||||
<li>Il fix viene applicato (Claude nel container DevEnv)</li>
|
||||
<li>Il flag viene rimosso — il prodotto torna online</li>
|
||||
<li>La pagina di manutenzione si auto-aggiorna e reindirizza gli utenti</li>
|
||||
</ol>
|
||||
<div class="warn-box">
|
||||
<svg class="icon" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning-circle"></use></svg>
|
||||
<div><strong>Nessun intervento manuale richiesto</strong>: il maintenance mode e completamente automatico. L'utente vede un messaggio professionale e il sistema torna online da solo.</div>
|
||||
</div>
|
||||
|
||||
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning"></use></svg> 5. Matrice Rischi e Mitigazione</h2>
|
||||
<table>
|
||||
<caption class="sr-only">Matrice rischi, probabilita, impatto, mitigazione e recovery</caption>
|
||||
<thead><tr><th scope="col">Scenario</th><th scope="col">Probabilita</th><th scope="col">Impatto</th><th scope="col">Mitigazione</th><th scope="col">Recovery</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Crash singolo servizio</td><td><span class="badge-warn">Media</span></td><td><span class="badge-warn">Medio</span></td><td>Auto-restart Docker/PM2</td><td>< 30s</td></tr>
|
||||
<tr><td>Database restart</td><td><span class="badge-ok">Bassa</span></td><td><span class="badge-crit">Alto</span></td><td>DB retry + reconnect</td><td>< 5s</td></tr>
|
||||
<tr><td>AI non disponibile</td><td><span class="badge-warn">Media</span></td><td><span class="badge-ok">Basso</span></td><td>3 retry + graceful</td><td>Funzioni base intatte</td></tr>
|
||||
<tr><td>Disco pieno</td><td><span class="badge-ok">Bassa</span></td><td><span class="badge-crit">Alto</span></td><td>Monitor 6h + cleanup</td><td>Alert prima del 85%</td></tr>
|
||||
<tr><td>SSL scaduto</td><td><span class="badge-ok">Bassa</span></td><td><span class="badge-crit">Alto</span></td><td>Auto-renewal certbot</td><td>Rinnovo 30gg prima</td></tr>
|
||||
<tr><td>Attacco brute force</td><td><span class="badge-warn">Media</span></td><td><span class="badge-warn">Medio</span></td><td>Rate limit + lockout</td><td>Blocco 15 min</td></tr>
|
||||
<tr><td>Errore deployment</td><td><span class="badge-warn">Media</span></td><td><span class="badge-crit">Alto</span></td><td>Maintenance mode + git revert</td><td>< 2 min rollback</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-card"></use></svg> 6. Service Level Agreement</h2>
|
||||
<table>
|
||||
<caption class="sr-only">Metriche di Service Level Agreement</caption>
|
||||
<thead><tr><th scope="col">Metrica</th><th scope="col">Target</th><th scope="col">Misurazione</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Uptime servizio</td><td><strong>99.9%</strong> (8.7h downtime/anno)</td><td>Health monitor + log</td></tr>
|
||||
<tr><td>Recovery da crash</td><td>< 30 secondi</td><td>Docker restart + healthcheck</td></tr>
|
||||
<tr><td>Recovery da aggiornamento</td><td>< 5 minuti</td><td>Maintenance mode + deploy</td></tr>
|
||||
<tr><td>Rilevamento downtime</td><td>< 5 minuti</td><td>Cron monitor + email alert</td></tr>
|
||||
<tr><td>Tempo risposta API (p95)</td><td>< 500ms</td><td>Health endpoint response time</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><svg class="icon icon-primary" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-check-circle"></use></svg> 7. Checklist Operativa</h2>
|
||||
<h3>Verifica automatica (quotidiana)</h3>
|
||||
<ul>
|
||||
<li>Tutti i container/processi in stato healthy/online</li>
|
||||
<li>Campanella AgileHub — nessuna notifica di errore sistema</li>
|
||||
<li>Email — nessun alert da health monitor</li>
|
||||
</ul>
|
||||
<h3>Prima di un aggiornamento manuale</h3>
|
||||
<ol>
|
||||
<li>Verificare che non ci siano utenti attivi (o attivare maintenance mode)</li>
|
||||
<li>Eseguire l'aggiornamento</li>
|
||||
<li>Verificare tutti i servizi: <code>docker compose ps</code> o <code>pm2 list</code></li>
|
||||
<li>Controllare i log per errori</li>
|
||||
</ol>
|
||||
|
||||
</main>
|
||||
<footer class="doc-footer">
|
||||
<div>© 2026 NIS2 Agile — <a href="https://nis2.agile.software">nis2.agile.software</a></div>
|
||||
<div>Standard Continuita di Servizio v1.0 • Aprile 2026 • Agile Technology SRL</div>
|
||||
</footer>
|
||||
</div>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,459 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Segnalazioni Sicurezza (BI) - NIS2 Agile</title>
|
||||
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di
|
||||
style.css cosi' il design system esistente resta autorevole per i widget
|
||||
renderizzati via JS (tabelle/modali/timeline) -> parita' funzionale assoluta.
|
||||
BI veste la chrome (sidebar + font istituzionale). -->
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.priority-badge { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; }
|
||||
.priority-critical { background: var(--danger-bg); color: var(--danger); }
|
||||
.priority-high { background: #fff7ed; color: #c2410c; }
|
||||
.priority-medium { background: var(--warning-bg); color: #a16207; }
|
||||
.priority-low { background: var(--gray-100); color: var(--gray-600); }
|
||||
.status-badge { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; }
|
||||
.status-received { background: rgba(6,182,212,0.1); color: #0891b2; }
|
||||
.status-under_review { background: var(--warning-bg); color: #a16207; }
|
||||
.status-investigating { background: #f3e8ff; color: #7c3aed; }
|
||||
.status-resolved, .status-closed { background: var(--success-bg); color: var(--success); }
|
||||
.status-rejected { background: var(--danger-bg); color: var(--danger); }
|
||||
.anonymous-shield { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; background: rgba(16,185,129,0.1); border-radius: 12px; color: var(--success); font-size: 0.75rem; font-weight: 600; }
|
||||
.art32-banner { background: linear-gradient(135deg, rgba(6,182,212,0.1), rgba(16,185,129,0.1)); border: 1px solid rgba(6,182,212,0.2); border-radius: var(--border-radius-lg); padding: 20px 24px; margin-bottom: 28px; }
|
||||
.art32-banner h3 { font-size: 0.9375rem; font-weight: 700; color: var(--gray-800); margin-bottom: 6px; }
|
||||
.art32-banner p { font-size: 0.8125rem; color: var(--gray-600); }
|
||||
.track-form { background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: var(--border-radius); padding: 20px; margin-bottom: 24px; }
|
||||
/* Icone sprite BI dimensionate inline per i bottoni azione */
|
||||
.icon-inline { width: 16px; height: 16px; fill: currentColor; vertical-align: text-bottom; }
|
||||
</style>
|
||||
</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">Segnalazioni Sicurezza</h1>
|
||||
<p class="page-subtitle">Canale interno Art.32 NIS2 — Segnala violazioni e anomalie di sicurezza</p>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn btn-secondary" onclick="switchView('track')">
|
||||
<svg class="icon-inline" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-search"></use></svg>
|
||||
Traccia Segnalazione Anonima
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="switchView('submit')">
|
||||
<svg class="icon-inline" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-plus"></use></svg>
|
||||
Nuova Segnalazione
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Art.32 Banner -->
|
||||
<div class="art32-banner">
|
||||
<h3>Art. 32 D.Lgs. 138/2024 — Canale Segnalazioni Interno</h3>
|
||||
<p>Le entità NIS2 devono predisporre canali interni per la segnalazione di violazioni alla sicurezza informatica. Le segnalazioni anonime sono protette e il segnalante non può essere identificato. Ogni segnalazione viene gestita con priorità e tracciabilità.</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid mb-24" id="stats-grid">
|
||||
<div class="stat-card"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Views -->
|
||||
<div id="view-list">
|
||||
<!-- Filters -->
|
||||
<div class="card mb-16">
|
||||
<div class="card-body" style="padding:16px 20px;">
|
||||
<div style="display:flex; gap:12px; flex-wrap:wrap; align-items:center;">
|
||||
<label for="filter-status" class="sr-only">Filtra per stato</label>
|
||||
<select id="filter-status" class="form-control" style="width:auto;" onchange="loadReports()" aria-label="Filtra per stato">
|
||||
<option value="">Tutti gli stati</option>
|
||||
<option value="received">Ricevute</option>
|
||||
<option value="under_review">In revisione</option>
|
||||
<option value="investigating">In indagine</option>
|
||||
<option value="resolved">Risolte</option>
|
||||
<option value="closed">Chiuse</option>
|
||||
</select>
|
||||
<label for="filter-priority" class="sr-only">Filtra per priorità</label>
|
||||
<select id="filter-priority" class="form-control" style="width:auto;" onchange="loadReports()" aria-label="Filtra per priorità">
|
||||
<option value="">Tutte le priorità</option>
|
||||
<option value="critical">Critica</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="medium">Media</option>
|
||||
<option value="low">Bassa</option>
|
||||
</select>
|
||||
<label for="filter-category" class="sr-only">Filtra per categoria</label>
|
||||
<select id="filter-category" class="form-control" style="width:auto;" onchange="loadReports()" aria-label="Filtra per categoria">
|
||||
<option value="">Tutte le categorie</option>
|
||||
<option value="security_incident">Incidente sicurezza</option>
|
||||
<option value="data_breach">Data breach</option>
|
||||
<option value="unauthorized_access">Accesso non autorizzato</option>
|
||||
<option value="policy_violation">Violazione policy</option>
|
||||
<option value="supply_chain_risk">Rischio supply chain</option>
|
||||
<option value="nis2_non_compliance">Non conformità NIS2</option>
|
||||
<option value="other">Altro</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reports-container"><div class="spinner" style="margin:60px auto;"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Form -->
|
||||
<div id="view-submit" style="display:none;">
|
||||
<div class="card" style="max-width:720px;">
|
||||
<div class="card-header">
|
||||
<h3>Nuova Segnalazione</h3>
|
||||
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Segnala anonimamente o con il tuo nome. L'anonimato è garantito dalla piattaforma.</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div style="margin-bottom:20px; padding:12px 16px; background:rgba(16,185,129,0.08); border:1px solid rgba(16,185,129,0.2); border-radius:var(--border-radius); display:flex; align-items:center; gap:10px;">
|
||||
<svg class="icon-inline" style="width:18px; height:18px; color:var(--success); flex-shrink:0;" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg>
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:0.875rem; cursor:pointer;">
|
||||
<input type="checkbox" id="is-anonymous" checked style="accent-color:var(--success);">
|
||||
<span><strong>Segnalazione anonima</strong> — La tua identità non verrà registrata</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<form id="submit-form" onsubmit="submitReport(event)">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="rep-category">Categoria *</label>
|
||||
<select id="rep-category" class="form-control" required>
|
||||
<option value="">Seleziona categoria...</option>
|
||||
<option value="security_incident">Incidente di sicurezza</option>
|
||||
<option value="data_breach">Data breach / Violazione dati</option>
|
||||
<option value="unauthorized_access">Accesso non autorizzato</option>
|
||||
<option value="policy_violation">Violazione policy interna</option>
|
||||
<option value="supply_chain_risk">Rischio supply chain</option>
|
||||
<option value="nis2_non_compliance">Non conformità NIS2</option>
|
||||
<option value="corruption">Corruzione / Frode</option>
|
||||
<option value="other">Altro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="rep-title">Titolo *</label>
|
||||
<input type="text" id="rep-title" class="form-control" placeholder="Breve descrizione dell'anomalia..." required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="rep-description">Descrizione dettagliata *</label>
|
||||
<textarea id="rep-description" class="form-control" rows="6" placeholder="Descrivi cosa hai osservato, quando è successo, chi è coinvolto (se noto), e qualsiasi altra informazione utile..." required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="rep-priority">Priorità stimata</label>
|
||||
<select id="rep-priority" class="form-control">
|
||||
<option value="medium">Media</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="critical">Critica — Azione immediata richiesta</option>
|
||||
<option value="low">Bassa</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="rep-article">Articolo NIS2 violato (opzionale)</label>
|
||||
<input type="text" id="rep-article" class="form-control" placeholder="Es. Art.21, Art.23...">
|
||||
</div>
|
||||
<div class="form-group" id="contact-email-group">
|
||||
<label class="form-label" for="rep-email">Email per follow-up (opzionale)</label>
|
||||
<input type="email" id="rep-email" class="form-control" placeholder="Email solo per ricevere aggiornamenti">
|
||||
<p style="font-size:0.75rem; color:var(--gray-500); margin-top:4px;">Lascia vuoto per mantenere l'anonimato completo. Oppure fornisci un'email temporanea.</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:12px; margin-top:24px;">
|
||||
<button type="submit" class="btn btn-primary">Invia Segnalazione</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="switchView('list')">Annulla</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Track Anonymous -->
|
||||
<div id="view-track" style="display:none;">
|
||||
<div class="card" style="max-width:600px;">
|
||||
<div class="card-header">
|
||||
<h3>Traccia Segnalazione Anonima</h3>
|
||||
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Inserisci il token ricevuto al momento della segnalazione per verificarne lo stato.</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="track-token">Token Segnalazione</label>
|
||||
<input type="text" id="track-token" class="form-control" placeholder="Inserisci il token...">
|
||||
</div>
|
||||
<div style="display:flex; gap:12px;">
|
||||
<button class="btn btn-primary" onclick="trackReport()">Verifica Stato</button>
|
||||
<button class="btn btn-secondary" onclick="switchView('list')">Annulla</button>
|
||||
</div>
|
||||
<div id="track-result" style="margin-top:24px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Report Detail Modal handled by common.js showModal -->
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/common.js"></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"></script>
|
||||
<script src="/js/i18n.js"></script>
|
||||
<script src="/js/help.js"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
I18n.init();
|
||||
if (typeof HelpSystem !== 'undefined') HelpSystem.init();
|
||||
|
||||
let currentView = 'list';
|
||||
|
||||
const categoryLabels = {
|
||||
security_incident: 'Incidente sicurezza', data_breach: 'Data breach',
|
||||
unauthorized_access: 'Accesso non autorizzato', policy_violation: 'Violazione policy',
|
||||
supply_chain_risk: 'Rischio supply chain', nis2_non_compliance: 'Non conformità NIS2',
|
||||
corruption: 'Corruzione/Frode', fraud: 'Frode', other: 'Altro'
|
||||
};
|
||||
|
||||
const priorityLabels = { critical: 'Critica', high: 'Alta', medium: 'Media', low: 'Bassa' };
|
||||
const statusLabels = {
|
||||
received: 'Ricevuta', under_review: 'In revisione', investigating: 'In indagine',
|
||||
resolved: 'Risolta', closed: 'Chiusa', rejected: 'Respinta'
|
||||
};
|
||||
|
||||
function switchView(view) {
|
||||
currentView = view;
|
||||
['list','submit','track'].forEach(v => {
|
||||
document.getElementById('view-' + v).style.display = v === view ? 'block' : 'none';
|
||||
});
|
||||
if (view === 'list') { loadStats(); loadReports(); }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const result = await api.request('GET', '/whistleblowing/stats');
|
||||
if (result.success) renderStats(result.data.stats);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function renderStats(s) {
|
||||
const grid = document.getElementById('stats-grid');
|
||||
grid.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Totale Segnalazioni</div>
|
||||
<div class="stat-value">${s.total || 0}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Da Gestire</div>
|
||||
<div class="stat-value" style="color:var(--warning);">${(s.received || 0) + (s.under_review || 0)}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">In Indagine</div>
|
||||
<div class="stat-value" style="color:var(--primary);">${s.investigating || 0}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Critiche/Alte</div>
|
||||
<div class="stat-value" style="color:var(--danger);">${(s.critical || 0) + (s.high || 0)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
const container = document.getElementById('reports-container');
|
||||
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
const status = document.getElementById('filter-status').value;
|
||||
const priority = document.getElementById('filter-priority').value;
|
||||
const category = document.getElementById('filter-category').value;
|
||||
if (status) params.append('status', status);
|
||||
if (priority) params.append('priority', priority);
|
||||
if (category) params.append('category', category);
|
||||
|
||||
const result = await api.request('GET', '/whistleblowing/list?' + params);
|
||||
if (result.success) renderReports(result.data.reports || []);
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="empty-state"><h4>Errore caricamento segnalazioni</h4></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderReports(reports) {
|
||||
const container = document.getElementById('reports-container');
|
||||
if (!reports.length) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg class="icon-inline" style="width:40px; height:40px; color:var(--gray-300);" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-warning-circle"></use></svg>
|
||||
<h4>Nessuna segnalazione trovata</h4>
|
||||
<p>Il canale di segnalazione è attivo. Le segnalazioni appariranno qui.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<div class="table-container"><table>
|
||||
<thead><tr><th scope="col">Codice</th><th scope="col">Categoria</th><th scope="col">Titolo</th><th scope="col">Priorità</th><th scope="col">Stato</th><th scope="col">Anonima</th><th scope="col">Assegnata</th><th scope="col">Data</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
|
||||
reports.forEach(r => {
|
||||
html += `<tr>
|
||||
<td><code style="font-size:0.8rem;">${escapeHtml(r.report_code)}</code></td>
|
||||
<td><span class="badge badge-neutral">${escapeHtml(categoryLabels[r.category] || r.category)}</span></td>
|
||||
<td><strong style="font-size:0.875rem;">${escapeHtml(r.title)}</strong></td>
|
||||
<td><span class="priority-badge priority-${r.priority}">${escapeHtml(priorityLabels[r.priority] || r.priority)}</span></td>
|
||||
<td><span class="status-badge status-${r.status}">${escapeHtml(statusLabels[r.status] || r.status)}</span></td>
|
||||
<td>${r.is_anonymous ? '<span class="anonymous-shield"><svg class="icon-inline" style="width:12px;height:12px;" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg>Anonima</span>' : '<span style="color:var(--gray-400); font-size:0.75rem;">Firmata</span>'}</td>
|
||||
<td>${r.assigned_to_name ? escapeHtml(r.assigned_to_name) : '<span style="color:var(--gray-400);">—</span>'}</td>
|
||||
<td style="font-size:0.8rem;">${formatDate(r.created_at)}</td>
|
||||
<td><button class="btn btn-sm btn-secondary" onclick="viewReport(${r.id})">Gestisci</button></td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function viewReport(id) {
|
||||
try {
|
||||
const result = await api.request('GET', `/whistleblowing/${id}`);
|
||||
if (!result.success) { showNotification('Errore caricamento.', 'error'); return; }
|
||||
const r = result.data;
|
||||
|
||||
const timelineHtml = (r.timeline || []).map(t => `
|
||||
<div style="padding:10px 0; border-bottom:1px solid var(--gray-100); display:flex; gap:12px;">
|
||||
<div style="width:8px; height:8px; border-radius:50%; background:var(--primary); margin-top:6px; flex-shrink:0;"></div>
|
||||
<div>
|
||||
<p style="font-size:0.8125rem; color:var(--gray-700);">${escapeHtml(t.description)}</p>
|
||||
<p style="font-size:0.75rem; color:var(--gray-400);">${formatDateTime(t.created_at)} ${t.created_by_name ? '— ' + escapeHtml(t.created_by_name) : ''}</p>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
showModal(`${escapeHtml(r.report_code)} — ${escapeHtml(r.title)}`, `
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap; margin-bottom:16px;">
|
||||
<span class="priority-badge priority-${r.priority}">${escapeHtml(priorityLabels[r.priority] || r.priority)}</span>
|
||||
<span class="status-badge status-${r.status}">${escapeHtml(statusLabels[r.status] || r.status)}</span>
|
||||
${r.is_anonymous ? '<span class="anonymous-shield"><svg class="icon-inline" style="width:12px;height:12px;" aria-hidden="true"><use href="/vendor/bootstrap-italia/dist/svg/sprites.svg#it-lock"></use></svg>Anonima</span>' : ''}
|
||||
</div>
|
||||
<div style="padding:12px 16px; background:var(--gray-50); border-radius:var(--border-radius); margin-bottom:16px; font-size:0.875rem; line-height:1.6;">
|
||||
${escapeHtml(r.description)}
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:16px; font-size:0.8125rem;">
|
||||
<div><strong>Categoria:</strong> ${escapeHtml(categoryLabels[r.category] || r.category)}</div>
|
||||
<div><strong>Art. NIS2:</strong> ${r.nis2_article ? escapeHtml(r.nis2_article) : '—'}</div>
|
||||
<div><strong>Assegnata a:</strong> ${r.assigned_to_name ? escapeHtml(r.assigned_to_name) : '—'}</div>
|
||||
<div><strong>Data:</strong> ${formatDateTime(r.created_at)}</div>
|
||||
</div>
|
||||
<h4 style="font-size:0.8125rem; font-weight:700; margin-bottom:8px;">Timeline</h4>
|
||||
<div>${timelineHtml || '<p style="color:var(--gray-400); font-size:0.8rem;">Nessun evento in timeline.</p>'}</div>
|
||||
<div style="margin-top:20px; display:flex; flex-direction:column; gap:10px;">
|
||||
<label for="modal-status" class="sr-only">Cambia stato</label>
|
||||
<select id="modal-status" class="form-control">
|
||||
<option value="">Cambia stato...</option>
|
||||
${['under_review','investigating','resolved','closed','rejected'].map(s =>
|
||||
`<option value="${s}" ${r.status === s ? 'selected' : ''}>${escapeHtml(statusLabels[s])}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<label for="modal-notes" class="sr-only">Note di risoluzione</label>
|
||||
<textarea id="modal-notes" class="form-control" rows="3" placeholder="Note di risoluzione...">${escapeHtml(r.resolution_notes || '')}</textarea>
|
||||
</div>
|
||||
`, `<button class="btn btn-primary" onclick="updateReport(${id})">Salva</button>
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>`);
|
||||
|
||||
} catch (e) { showNotification('Errore di connessione.', 'error'); }
|
||||
}
|
||||
|
||||
async function updateReport(id) {
|
||||
const status = document.getElementById('modal-status').value;
|
||||
const notes = document.getElementById('modal-notes').value;
|
||||
const body = {};
|
||||
if (status) body.status = status;
|
||||
if (notes) body.resolution_notes = notes;
|
||||
if (!Object.keys(body).length) { closeModal(); return; }
|
||||
|
||||
try {
|
||||
const result = await api.request('PUT', `/whistleblowing/${id}`, body);
|
||||
if (result.success) {
|
||||
closeModal();
|
||||
showNotification('Segnalazione aggiornata.', 'success');
|
||||
loadReports(); loadStats();
|
||||
} else showNotification(result.message || 'Errore.', 'error');
|
||||
} catch (e) { showNotification('Errore di connessione.', 'error'); }
|
||||
}
|
||||
|
||||
async function submitReport(e) {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('[type=submit]');
|
||||
btn.disabled = true; btn.textContent = 'Invio...';
|
||||
|
||||
try {
|
||||
const body = {
|
||||
category: document.getElementById('rep-category').value,
|
||||
title: document.getElementById('rep-title').value,
|
||||
description: document.getElementById('rep-description').value,
|
||||
priority: document.getElementById('rep-priority').value,
|
||||
nis2_article: document.getElementById('rep-article').value,
|
||||
is_anonymous: document.getElementById('is-anonymous').checked ? 1 : 0,
|
||||
contact_email: document.getElementById('rep-email').value,
|
||||
organization_id: localStorage.getItem('nis2_org_id'),
|
||||
};
|
||||
|
||||
const result = await api.request('POST', '/whistleblowing/submit', body);
|
||||
if (result.success) {
|
||||
const token = result.data.anonymous_token;
|
||||
showModal('Segnalazione Inviata', `
|
||||
<div style="padding:16px; background:rgba(16,185,129,0.1); border-radius:var(--border-radius); margin-bottom:16px;">
|
||||
<p style="font-weight:700; color:var(--success);">Segnalazione ricevuta con codice: <code>${escapeHtml(result.data.report_code)}</code></p>
|
||||
</div>
|
||||
${token ? `<p style="font-size:0.875rem; color:var(--gray-600); margin-bottom:8px;">Token per tracking anonimo (conservalo):</p>
|
||||
<div style="padding:12px; background:var(--gray-100); border-radius:var(--border-radius); font-family:monospace; font-size:0.8rem; word-break:break-all; user-select:all;">${escapeHtml(token)}</div>` : ''}
|
||||
`, `<button class="btn btn-primary" onclick="closeModal(); switchView('list');">OK</button>`);
|
||||
e.target.reset();
|
||||
} else {
|
||||
showNotification(result.message || 'Errore.', 'error');
|
||||
}
|
||||
} catch (err) { showNotification('Errore di connessione.', 'error'); }
|
||||
finally { btn.disabled = false; btn.textContent = 'Invia Segnalazione'; }
|
||||
}
|
||||
|
||||
async function trackReport() {
|
||||
const token = document.getElementById('track-token').value.trim();
|
||||
if (!token) { showNotification('Inserisci il token.', 'error'); return; }
|
||||
|
||||
const container = document.getElementById('track-result');
|
||||
container.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const result = await api.request('GET', '/whistleblowing/track-anonymous?token=' + encodeURIComponent(token));
|
||||
if (result.success) {
|
||||
const r = result.data;
|
||||
const tl = (r.timeline || []).map(t => `
|
||||
<div style="display:flex; gap:10px; padding:8px 0; border-bottom:1px solid var(--gray-100);">
|
||||
<div style="width:6px; height:6px; border-radius:50%; background:var(--primary); margin-top:7px; flex-shrink:0;"></div>
|
||||
<div><p style="font-size:0.8125rem;">${escapeHtml(t.description)}</p>
|
||||
<p style="font-size:0.75rem; color:var(--gray-400);">${formatDateTime(t.created_at)}</p></div>
|
||||
</div>`).join('');
|
||||
container.innerHTML = `
|
||||
<div style="padding:16px; border:1px solid var(--gray-200); border-radius:var(--border-radius);">
|
||||
<div style="display:flex; gap:10px; margin-bottom:12px;">
|
||||
<code style="font-size:0.875rem;">${escapeHtml(r.report_code)}</code>
|
||||
<span class="status-badge status-${r.status}">${escapeHtml(statusLabels[r.status] || r.status)}</span>
|
||||
</div>
|
||||
<p style="font-size:0.8125rem; color:var(--gray-600); margin-bottom:12px;">Categoria: ${escapeHtml(categoryLabels[r.category] || r.category)}</p>
|
||||
<div>${tl}</div>
|
||||
</div>`;
|
||||
} else {
|
||||
container.innerHTML = '<div class="empty-state"><h4>Token non valido o segnalazione non trovata</h4></div>';
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Init
|
||||
loadStats();
|
||||
loadReports();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user