Integra la spec di Simon per la pagina Rischi con le correzioni normative (parere nis2-expert + iso-27001-expert) applicate come eccezioni motivate: - 12 colonne: Codice (link → Misure e Requisiti), Descrizione (RISCHIO DEFAULT = cfg_nis2_rischi.risk_descr), VALUTAZIONE RISCHIO INERENTE (P/I/PxI), Alert, Rischio Inerente, VALUTAZIONE RISCHIO RESIDUO (P/I/PxI), Esito residuo, Rischio Residuo. - Editing INLINE di Probabilità/Impatto (1-5), salvataggio automatico. - Alert a bande (15-25 Prioritaria / 9-14 Critica / 3-8 Necessaria / 1-2 Suggerita) → popup azioni + "crea azione" (tabella requisito_actions, seme Modulo Azioni). - Eccezione #1: il rischio RESIDUO NON aggiorna in automatico lo STATO DI CONFORMITÀ del requisito (piani distinti). Col.11 "Esito residuo" = flag Rivalutare/Adeguato; conformità esplicita. - Eccezione #3: banda 13-14 inclusa in "Critica" (9-14). "Danno €" → "Impatto" (1-5). Soglie = metodo interno (ISO 27001 §6.1.2), non obbligo (help/UI lo dichiarano). Backend: mig 067; derivedList (+risk_descr +residuo), setState (+residuo), requisitoActions list/create. help.js + i18n IT/EN. Cache-buster api/i18n/help 20260730a. Prossima mig=068. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1225 lines
62 KiB
HTML
1225 lines
62 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="it">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<title>Policy · NIS2 Agile</title>
|
|
<!-- UI V3: Inter self-host + design system v3.css (self-host only, nessun CDN esterno). -->
|
|
<link rel="stylesheet" href="/vendor/inter/inter.css?v=20260627">
|
|
<link rel="stylesheet" href="/css/v3.css?v=20260627">
|
|
<!-- style.css resta caricato DOPO v3.css: veste i widget renderizzati via JS
|
|
(tabelle/editor/modali/badge/tab). Self-hostato (nessun CDN). v3.css governa chrome + shell. -->
|
|
<style>
|
|
/* ── Policy Page Styles ──────────────────────────────────── */
|
|
.filters-bar {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 20px;
|
|
}
|
|
.filters-bar .form-select {
|
|
min-width: 180px;
|
|
padding: 8px 12px;
|
|
font-size: 0.85rem;
|
|
}
|
|
.filters-bar .btn {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.policy-actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
}
|
|
.policy-actions .btn {
|
|
padding: 5px 10px;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
gap: 0;
|
|
border-bottom: 2px solid var(--gray-200);
|
|
margin-bottom: 24px;
|
|
}
|
|
.tab-btn {
|
|
padding: 10px 20px;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--gray-500);
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
border-bottom: 2px solid transparent;
|
|
margin-bottom: -2px;
|
|
transition: all var(--transition-fast);
|
|
}
|
|
.tab-btn:hover {
|
|
color: var(--gray-700);
|
|
}
|
|
.tab-btn.active {
|
|
color: var(--primary);
|
|
border-bottom-color: var(--primary);
|
|
}
|
|
.tab-content {
|
|
display: none;
|
|
}
|
|
.tab-content.active {
|
|
display: block;
|
|
}
|
|
|
|
.template-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 16px;
|
|
}
|
|
.template-card {
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--gray-200);
|
|
border-radius: var(--border-radius);
|
|
padding: 20px;
|
|
cursor: pointer;
|
|
transition: all var(--transition-fast);
|
|
}
|
|
.template-card:hover {
|
|
border-color: var(--primary);
|
|
box-shadow: var(--card-shadow-hover);
|
|
transform: translateY(-2px);
|
|
}
|
|
.template-card h4 {
|
|
font-size: 0.9rem;
|
|
color: var(--gray-800);
|
|
margin-bottom: 8px;
|
|
}
|
|
.template-card .template-meta {
|
|
font-size: 0.78rem;
|
|
color: var(--gray-500);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.template-card .template-meta .tag {
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
.detail-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: 24px;
|
|
gap: 16px;
|
|
}
|
|
.detail-header h3 {
|
|
font-size: 1.25rem;
|
|
color: var(--gray-900);
|
|
}
|
|
.detail-header-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.detail-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 300px;
|
|
gap: 24px;
|
|
}
|
|
@media (max-width: 900px) {
|
|
.detail-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.policy-content-box {
|
|
background: var(--gray-50);
|
|
border: 1px solid var(--gray-200);
|
|
border-radius: var(--border-radius);
|
|
padding: 24px;
|
|
white-space: pre-wrap;
|
|
font-size: 0.875rem;
|
|
line-height: 1.7;
|
|
color: var(--gray-700);
|
|
min-height: 200px;
|
|
max-height: 600px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.meta-card {
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--gray-200);
|
|
border-radius: var(--border-radius);
|
|
padding: 20px;
|
|
}
|
|
.meta-card h4 {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--gray-800);
|
|
margin-bottom: 16px;
|
|
padding-bottom: 12px;
|
|
border-bottom: 1px solid var(--gray-100);
|
|
}
|
|
.meta-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 8px 0;
|
|
font-size: 0.82rem;
|
|
border-bottom: 1px solid var(--gray-50);
|
|
}
|
|
.meta-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.meta-label {
|
|
color: var(--gray-500);
|
|
font-weight: 500;
|
|
}
|
|
.meta-value {
|
|
color: var(--gray-800);
|
|
font-weight: 600;
|
|
text-align: right;
|
|
}
|
|
|
|
.ai-preview {
|
|
background: var(--gray-50);
|
|
border: 1px solid var(--gray-200);
|
|
border-radius: var(--border-radius);
|
|
padding: 20px;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
white-space: pre-wrap;
|
|
font-size: 0.85rem;
|
|
line-height: 1.6;
|
|
margin-top: 12px;
|
|
}
|
|
.ai-preview-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
color: var(--primary);
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
}
|
|
.ai-preview-header svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
|
|
.loading-overlay {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 60px 20px;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
color: var(--gray-500);
|
|
}
|
|
.loading-overlay .spinner {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 3px solid var(--gray-200);
|
|
border-top-color: var(--primary);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.text-link {
|
|
color: var(--primary);
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
}
|
|
.text-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.content-header-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
</style>
|
|
<!-- PWA:start -->
|
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
<meta name="theme-color" content="#0066CC">
|
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32.png">
|
|
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16.png">
|
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
<meta name="apple-mobile-web-app-title" content="NIS2 Agile">
|
|
<meta name="application-name" content="NIS2 Agile">
|
|
<script src="/js/pwa.js?v=20260614" defer></script>
|
|
<!-- PWA:end -->
|
|
</head>
|
|
<body>
|
|
<!-- Chrome V3 (appbar + mega-menu) iniettata da topnav-v3.js in questo host -->
|
|
<div id="topnav"></div>
|
|
|
|
<main class="wrap">
|
|
<nav class="crumb" aria-label="breadcrumb">
|
|
<i data-lucide="home" class="ic"></i><a href="dashboard.html">Home</a>
|
|
<i data-lucide="chevron-right" class="ic"></i><span data-i18n="policies.title">Gestione Policy</span>
|
|
</nav>
|
|
<div class="page-head">
|
|
<div>
|
|
<h1 data-i18n="policies.title">Gestione Policy</h1>
|
|
<p class="sub">Politiche di sicurezza: bozze, revisione, approvazione e template NIS2</p>
|
|
</div>
|
|
<div class="content-header-actions" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
|
<button id="nis2-policies-btn-ai-generate" class="btn-ghost" onclick="openAIGenerateModal()">
|
|
<i data-lucide="sparkles" class="ic"></i>Genera con AI
|
|
</button>
|
|
<button id="nis2-policies-btn-new" class="btn-primary" onclick="openCreateModal()"><i data-lucide="plus" class="ic"></i>Nuova Policy</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content-body" id="page-content">
|
|
<!-- Tabs -->
|
|
<div class="tabs" id="nis2-policies-tabs">
|
|
<button id="nis2-policies-tab-btn-list" class="tab-btn active" onclick="switchTab('list')">Elenco Policy</button>
|
|
<button id="nis2-policies-tab-btn-templates" class="tab-btn" onclick="switchTab('templates')">Template</button>
|
|
</div>
|
|
|
|
<!-- Tab: Lista Policy -->
|
|
<div class="tab-content active" id="tab-list">
|
|
<div class="filters-bar">
|
|
<select class="form-select" id="filter-category" onchange="loadPolicies()" aria-label="Filtra per categoria">
|
|
<option value="">Tutte le Categorie</option>
|
|
<option value="information_security">Sicurezza Informazioni</option>
|
|
<option value="access_control">Controllo Accessi</option>
|
|
<option value="incident_response">Risposta Incidenti</option>
|
|
<option value="business_continuity">Continuita' Operativa</option>
|
|
<option value="supply_chain">Supply Chain</option>
|
|
<option value="encryption">Crittografia</option>
|
|
<option value="hr_security">Sicurezza HR</option>
|
|
<option value="asset_management">Gestione Asset</option>
|
|
<option value="network_security">Sicurezza Rete</option>
|
|
<option value="vulnerability_management">Gestione Vulnerabilita'</option>
|
|
</select>
|
|
<select class="form-select" id="filter-status" onchange="loadPolicies()" aria-label="Filtra per stato">
|
|
<option value="">Tutti gli Stati</option>
|
|
<option value="draft">Bozza</option>
|
|
<option value="review">In Revisione</option>
|
|
<option value="approved">Approvata</option>
|
|
<option value="published">Pubblicata</option>
|
|
<option value="archived">Archiviata</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="table-container" id="policies-table">
|
|
<div class="loading-overlay">
|
|
<div class="spinner"></div>
|
|
<span>Caricamento policy...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Template -->
|
|
<div class="tab-content" id="tab-templates">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3>Template Policy NIS2</h3>
|
|
</div>
|
|
<div class="card-body" id="templates-grid">
|
|
<div class="loading-overlay">
|
|
<div class="spinner"></div>
|
|
<span>Caricamento template...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detail View (hidden by default) -->
|
|
<div id="policy-detail-view" style="display:none;"></div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Scripts V3: api.js → common.js (helper+FAB reali) → Lucide self-host →
|
|
topnav-v3.js (chrome, ridefinisce loadSidebar) → i18n → help → pagina -->
|
|
<script src="/js/api.js?v=20260730a"></script>
|
|
<script src="/js/common.js?v=20260729b"></script>
|
|
<script src="/vendor/lucide/lucide.min.js"></script>
|
|
<script src="/js/topnav-v3.js?v=20260729"></script>
|
|
<script src="/js/i18n.js?v=20260730a"></script>
|
|
<script src="/js/help.js?v=20260730a"></script>
|
|
<script>
|
|
// ── Auth & Init ─────────────────────────────────────────
|
|
if (!checkAuth()) throw new Error('Not authenticated');
|
|
loadSidebar(); // = loadTopnavV3() (definito in topnav-v3.js)
|
|
I18n.init();
|
|
HelpSystem.init();
|
|
if (window.lucide && lucide.createIcons) { try { lucide.createIcons(); } catch (e) {} }
|
|
|
|
// ── Labels ──────────────────────────────────────────────
|
|
const CATEGORY_LABELS = {
|
|
information_security: 'Sicurezza Informazioni',
|
|
access_control: 'Controllo Accessi',
|
|
incident_response: 'Risposta Incidenti',
|
|
business_continuity: 'Continuita\' Operativa',
|
|
supply_chain: 'Supply Chain',
|
|
encryption: 'Crittografia',
|
|
hr_security: 'Sicurezza HR',
|
|
asset_management: 'Gestione Asset',
|
|
network_security: 'Sicurezza Rete',
|
|
vulnerability_management: 'Gestione Vulnerabilita\''
|
|
};
|
|
|
|
const STATUS_LABELS = {
|
|
draft: 'Bozza',
|
|
review: 'In Revisione',
|
|
approved: 'Approvata',
|
|
published: 'Pubblicata',
|
|
archived: 'Archiviata'
|
|
};
|
|
|
|
const STATUS_BADGE_CLASS = {
|
|
draft: 'badge-neutral',
|
|
review: 'badge-info',
|
|
approved: 'badge-success',
|
|
published: 'badge-primary',
|
|
archived: 'badge-neutral'
|
|
};
|
|
|
|
// ── State ───────────────────────────────────────────────
|
|
let currentView = 'list'; // 'list' | 'detail'
|
|
let policiesData = [];
|
|
|
|
// ── Tab switching ───────────────────────────────────────
|
|
function switchTab(tab) {
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
|
|
if (tab === 'list') {
|
|
document.querySelectorAll('.tab-btn')[0].classList.add('active');
|
|
document.getElementById('tab-list').classList.add('active');
|
|
} else if (tab === 'templates') {
|
|
document.querySelectorAll('.tab-btn')[1].classList.add('active');
|
|
document.getElementById('tab-templates').classList.add('active');
|
|
loadTemplates();
|
|
}
|
|
|
|
// Hide detail view when switching tabs
|
|
document.getElementById('policy-detail-view').style.display = 'none';
|
|
document.querySelector('.tabs').style.display = '';
|
|
document.getElementById('tab-list').style.display = '';
|
|
document.getElementById('tab-templates').style.display = '';
|
|
currentView = 'list';
|
|
}
|
|
|
|
// Ticket #384 p.2.2/p.3 — colonna "Requisito NIS2" multi-valore con link attivi a MISURE E REQUISITI.
|
|
// Il codice mostrato e' MISURA + REQUISITO (dal backend, tabella requisito_policy). In assenza di
|
|
// associazioni degrada al campo nis2_article, com'era prima.
|
|
function renderReqLinks(p) {
|
|
const reqs = (p && p.requisiti) || [];
|
|
if (reqs.length) {
|
|
return reqs.map(r =>
|
|
`<a class="text-link" href="misure-requisiti.html?requisito=${r.id}" title="Vai al requisito in Misure e Requisiti">${escapeHtml(r.code || r.misura_code || '')}</a>`
|
|
).join(', ');
|
|
}
|
|
return escapeHtml(p.nis2_article || '-');
|
|
}
|
|
|
|
// ── Load Policies ───────────────────────────────────────
|
|
async function loadPolicies() {
|
|
const params = {};
|
|
const category = document.getElementById('filter-category').value;
|
|
const status = document.getElementById('filter-status').value;
|
|
if (category) params.category = category;
|
|
if (status) params.status = status;
|
|
|
|
const container = document.getElementById('policies-table');
|
|
container.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento policy...</span></div>';
|
|
|
|
try {
|
|
const result = await api.listPolicies(params);
|
|
if (result.success) {
|
|
policiesData = result.data || [];
|
|
renderPoliciesTable(policiesData);
|
|
} 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><p>Impossibile caricare le policy.</p></div>';
|
|
}
|
|
}
|
|
|
|
function renderPoliciesTable(policies) {
|
|
const container = document.getElementById('policies-table');
|
|
|
|
if (!policies || policies.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state" style="padding:40px 20px;">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" style="width:48px;height:48px;color:var(--gray-300);">
|
|
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<h4>Nessuna policy trovata</h4>
|
|
<p>Crea la tua prima policy o utilizza un template per iniziare.</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
let html = `
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Titolo</th>
|
|
<th>Categoria</th>
|
|
<th>Requisito NIS2</th>
|
|
<th>Versione</th>
|
|
<th>Stato</th>
|
|
<th>Prossima Revisione</th>
|
|
<th>Azioni</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>`;
|
|
|
|
policies.forEach(p => {
|
|
const catLabel = CATEGORY_LABELS[p.category] || p.category || '-';
|
|
const statusLabel = STATUS_LABELS[p.status] || p.status || 'Bozza';
|
|
const badgeClass = STATUS_BADGE_CLASS[p.status] || 'badge-neutral';
|
|
|
|
html += `
|
|
<tr>
|
|
<td>
|
|
<span class="text-link" onclick="viewPolicy(${p.id})">${escapeHtml(p.title)}</span>
|
|
</td>
|
|
<td>${escapeHtml(catLabel)}</td>
|
|
<td>${renderReqLinks(p)}</td>
|
|
<td>${escapeHtml(p.version || '1.0')}</td>
|
|
<td><span class="badge ${badgeClass}">${escapeHtml(statusLabel)}</span></td>
|
|
<td>${formatDate(p.next_review_date)}</td>
|
|
<td>
|
|
<div class="policy-actions">
|
|
<button class="btn btn-sm btn-ghost" onclick="viewPolicy(${p.id})" title="Visualizza">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
|
|
</button>
|
|
<button class="btn btn-sm btn-ghost" onclick="openEditModal(${p.id})" title="Modifica">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
|
</button>
|
|
${p.status === 'draft' || p.status === 'review' ? `
|
|
<button class="btn btn-sm btn-success" onclick="approvePolicy(${p.id})" title="Approva">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
|
</button>` : ''}
|
|
<button class="btn btn-sm btn-ghost" onclick="confirmDeletePolicy(${p.id}, '${escapeHtml(p.title).replace(/'/g, "\\'")}')" title="Elimina" style="color:var(--danger);">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// ── View Policy Detail ──────────────────────────────────
|
|
async function viewPolicy(id) {
|
|
// Hide tabs and list
|
|
document.querySelector('.tabs').style.display = 'none';
|
|
document.getElementById('tab-list').classList.remove('active');
|
|
document.getElementById('tab-templates').classList.remove('active');
|
|
|
|
const detailView = document.getElementById('policy-detail-view');
|
|
detailView.style.display = 'block';
|
|
detailView.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento dettagli...</span></div>';
|
|
currentView = 'detail';
|
|
|
|
try {
|
|
const result = await api.getPolicy(id);
|
|
if (result.success && result.data) {
|
|
renderPolicyDetail(result.data);
|
|
} else {
|
|
detailView.innerHTML = '<div class="empty-state"><h4>Policy non trovata</h4></div>';
|
|
}
|
|
} catch (e) {
|
|
detailView.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
|
|
}
|
|
}
|
|
|
|
function renderPolicyDetail(policy) {
|
|
const detailView = document.getElementById('policy-detail-view');
|
|
const catLabel = CATEGORY_LABELS[policy.category] || policy.category || '-';
|
|
const statusLabel = STATUS_LABELS[policy.status] || policy.status || 'Bozza';
|
|
const badgeClass = STATUS_BADGE_CLASS[policy.status] || 'badge-neutral';
|
|
|
|
detailView.innerHTML = `
|
|
<div class="detail-header">
|
|
<div>
|
|
<div style="margin-bottom:8px;">
|
|
<span class="text-link" onclick="backToList()" style="font-size:0.82rem;">
|
|
← Torna all'elenco
|
|
</span>
|
|
</div>
|
|
<h3>${escapeHtml(policy.title)}</h3>
|
|
<div style="margin-top:8px;">
|
|
<span class="badge ${badgeClass}">${escapeHtml(statusLabel)}</span>
|
|
${policy.ai_generated ? '<span class="badge badge-info" style="margin-left:6px;">AI Generata</span>' : ''}
|
|
</div>
|
|
${policy.ai_generated && (policy.status === 'draft' || policy.status === 'review') ? `
|
|
<div id="ai-draft-warning-${policy.id}" style="background:#fff7ed; border:1px solid #fed7aa; border-radius:var(--border-radius); padding:12px 16px; margin-top:12px; display:flex; gap:10px; align-items:flex-start; position:relative;">
|
|
<svg viewBox="0 0 20 20" fill="#c2410c" width="18" height="18" style="flex-shrink:0; margin-top:2px;"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
|
<div style="flex:1;">
|
|
<strong style="color:#c2410c; font-size:0.875rem;">Bozza AI — Revisione obbligatoria prima dell'approvazione</strong>
|
|
<p style="font-size:0.78rem; color:#92400e; margin:4px 0 0;">Questo documento e' stato generato automaticamente da un sistema AI. Prima di approvarlo, verificare che sia conforme alle procedure interne, al quadro normativo applicabile e alla realta' specifica dell'organizzazione. L'AI puo' produrre contenuti imprecisi o incompleti.</p>
|
|
</div>
|
|
<button onclick="document.getElementById('ai-draft-warning-${policy.id}').remove();" title="Chiudi" aria-label="Chiudi avviso"
|
|
style="flex-shrink:0;background:none;border:none;cursor:pointer;color:#c2410c;padding:2px;line-height:1;opacity:0.7;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.7">
|
|
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
|
</button>
|
|
</div>` : ''}
|
|
</div>
|
|
<div class="detail-header-actions">
|
|
${policy.status === 'draft' || policy.status === 'review' ? `
|
|
<button class="btn btn-success" onclick="approvePolicy(${policy.id})">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
|
Approva
|
|
</button>` : ''}
|
|
<button class="btn btn-secondary" onclick="openEditModal(${policy.id})">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
|
Modifica
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="detail-grid">
|
|
<div>
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3>Contenuto Policy</h3>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="policy-content-box">${escapeHtml(policy.content || 'Nessun contenuto disponibile.')}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="meta-card">
|
|
<h4>Informazioni</h4>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Categoria</span>
|
|
<span class="meta-value">${escapeHtml(catLabel)}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Requisito NIS2</span>
|
|
<span class="meta-value">${escapeHtml(policy.nis2_article || '-')}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Versione</span>
|
|
<span class="meta-value">${escapeHtml(policy.version || '1.0')}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Stato</span>
|
|
<span class="meta-value"><span class="badge ${badgeClass}">${escapeHtml(statusLabel)}</span></span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Creata il</span>
|
|
<span class="meta-value">${formatDate(policy.created_at)}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Aggiornata il</span>
|
|
<span class="meta-value">${formatDate(policy.updated_at)}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Prossima Revisione</span>
|
|
<span class="meta-value">${formatDate(policy.next_review_date)}</span>
|
|
</div>
|
|
${policy.approved_by_name ? `
|
|
<div class="meta-row">
|
|
<span class="meta-label">Approvata da</span>
|
|
<span class="meta-value">${escapeHtml(policy.approved_by_name)}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-label">Approvata il</span>
|
|
<span class="meta-value">${formatDate(policy.approved_at)}</span>
|
|
</div>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" style="margin-top:20px;">
|
|
<div class="card-header">
|
|
<h3>Allegati</h3>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="policy-attachments-${policy.id}" aria-live="polite">
|
|
<p style="color:var(--gray-500);font-size:0.85rem;margin:0;">Caricamento allegati...</p>
|
|
</div>
|
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px;padding-top:14px;border-top:1px solid var(--gray-200);">
|
|
<input type="file" id="policy-att-file-${policy.id}" class="form-input" style="flex:1;min-width:220px;"
|
|
accept=".pdf,.png,.jpg,.jpeg,.gif,.webp,.txt,.csv,.xlsx,.xls,.docx,.doc,.pptx,.ppt,.odt,.ods,.zip"
|
|
aria-label="Seleziona un file da allegare alla policy" aria-describedby="policy-att-hint-${policy.id}">
|
|
<button class="btn btn-primary" onclick="caricaAllegato(${policy.id})">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M10 3a1 1 0 01.7.29l3 3a1 1 0 11-1.4 1.42L11 6.4V13a1 1 0 11-2 0V6.4L7.7 7.71a1 1 0 01-1.4-1.42l3-3A1 1 0 0110 3zM4 15a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1z"/></svg>
|
|
Carica allegato
|
|
</button>
|
|
</div>
|
|
<p id="policy-att-hint-${policy.id}" style="font-size:0.78rem;color:var(--gray-500);margin:8px 0 0;">Formati ammessi: PDF, immagini, documenti Office, txt, csv, zip. Dimensione massima 10 MB.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" style="margin-top:20px;">
|
|
<div class="card-header">
|
|
<h3>Matrice RACI</h3>
|
|
</div>
|
|
<div class="card-body">
|
|
<p style="font-size:0.85rem;color:var(--gray-600);margin:0 0 14px;">
|
|
Assegna le responsabilita' ai ruoli dell'<a class="text-link" href="organigramma.html">organigramma</a>:
|
|
<strong>R</strong> Responsible (esegue), <strong>A</strong> Accountable (approva/risponde),
|
|
<strong>C</strong> Consulted (consultato), <strong>I</strong> Informed (informato).
|
|
In bozza l'assegnazione e' facoltativa; <strong>per approvare la policy sono obbligatori almeno un ruolo R e uno A.</strong>
|
|
</p>
|
|
<div id="policy-raci-${policy.id}" aria-live="polite">
|
|
<p style="color:var(--gray-500);font-size:0.85rem;margin:0;">Caricamento matrice RACI...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
loadPolicyAttachments(policy.id);
|
|
loadPolicyRaci(policy.id);
|
|
}
|
|
|
|
function backToList() {
|
|
document.getElementById('policy-detail-view').style.display = 'none';
|
|
document.querySelector('.tabs').style.display = '';
|
|
switchTab('list');
|
|
currentView = 'list';
|
|
}
|
|
|
|
// ── Allegati Policy (ticket #384 punto 5) ───────────────
|
|
const ATT_ALLOWED_EXT = ['pdf','png','jpg','jpeg','gif','webp','txt','csv','xlsx','xls','docx','doc','pptx','ppt','odt','ods','zip'];
|
|
|
|
function fmtFileSize(bytes) {
|
|
if (bytes === null || bytes === undefined || bytes === '') return '';
|
|
bytes = Number(bytes);
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
}
|
|
|
|
async function loadPolicyAttachments(policyId) {
|
|
const box = document.getElementById('policy-attachments-' + policyId);
|
|
if (!box) return;
|
|
try {
|
|
const headers = { 'Authorization': 'Bearer ' + api.token };
|
|
if (api.orgId) headers['X-Organization-Id'] = api.orgId;
|
|
const resp = await fetch(api.baseUrl + '/audit/evidence/list?entity_type=policy&entity_id=' + policyId, { headers });
|
|
const result = await resp.json();
|
|
const files = (result && result.success && Array.isArray(result.data)) ? result.data : [];
|
|
if (!files.length) {
|
|
box.innerHTML = '<p style="color:var(--gray-500);font-size:0.85rem;margin:0;">Nessun allegato. Carica un documento di supporto alla policy.</p>';
|
|
return;
|
|
}
|
|
box.innerHTML = files.map(f => `
|
|
<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--gray-100);">
|
|
<svg viewBox="0 0 20 20" fill="var(--primary, #0066CC)" width="18" height="18" style="flex-shrink:0;"><path d="M8 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0017.414 6L14 2.586A2 2 0 0012.586 2H8z"/></svg>
|
|
<a href="/uploads/${escapeHtml(f.file_path)}" target="_blank" rel="noopener" style="flex:1;word-break:break-all;">${escapeHtml(f.file_name)}</a>
|
|
<span style="color:var(--gray-500);font-size:0.78rem;white-space:nowrap;">${fmtFileSize(f.file_size)}</span>
|
|
<span style="color:var(--gray-500);font-size:0.78rem;white-space:nowrap;">${formatDate(f.created_at)}${f.uploaded_by_name ? ' · ' + escapeHtml(f.uploaded_by_name) : ''}</span>
|
|
</div>
|
|
`).join('');
|
|
} catch (e) {
|
|
box.innerHTML = '<p style="color:var(--danger, #b91c1c);font-size:0.85rem;margin:0;">Errore nel caricamento degli allegati.</p>';
|
|
}
|
|
}
|
|
|
|
async function caricaAllegato(policyId) {
|
|
const input = document.getElementById('policy-att-file-' + policyId);
|
|
if (!input || !input.files || !input.files.length) {
|
|
showNotification('Seleziona un file da allegare.', 'warning');
|
|
return;
|
|
}
|
|
const file = input.files[0];
|
|
const ext = (file.name.split('.').pop() || '').toLowerCase();
|
|
if (!ATT_ALLOWED_EXT.includes(ext)) {
|
|
showNotification('Tipo di file non consentito. Formati ammessi: PDF, immagini, documenti Office, txt, csv, zip.', 'error');
|
|
return;
|
|
}
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
showNotification('File troppo grande (max 10 MB).', 'error');
|
|
return;
|
|
}
|
|
const btn = input.parentElement.querySelector('.btn-primary');
|
|
setButtonLoading(btn, true);
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
fd.append('entity_type', 'policy');
|
|
fd.append('entity_id', policyId);
|
|
const headers = { 'Authorization': 'Bearer ' + api.token };
|
|
if (api.orgId) headers['X-Organization-Id'] = api.orgId;
|
|
const resp = await fetch(api.baseUrl + '/audit/evidence/upload', { method: 'POST', headers, body: fd });
|
|
const result = await resp.json();
|
|
setButtonLoading(btn, false);
|
|
if (result && result.success) {
|
|
showNotification('Allegato caricato con successo.', 'success');
|
|
input.value = '';
|
|
loadPolicyAttachments(policyId);
|
|
} else {
|
|
showNotification((result && result.message) || 'Errore nel caricamento dell\'allegato.', 'error');
|
|
}
|
|
} catch (e) {
|
|
setButtonLoading(btn, false);
|
|
showNotification('Errore di connessione durante il caricamento.', 'error');
|
|
}
|
|
}
|
|
|
|
// ── Matrice RACI Policy (ticket #384 punto 6) ───────────────
|
|
// Riusa l'infrastruttura RACI esistente (org_roles + raci_assignments,
|
|
// object_type='procedure' -> policy_id). Nessuna migrazione.
|
|
const RACI_COLS = ['', 'R', 'A', 'C', 'I'];
|
|
const RACI_TITLES = {
|
|
'': 'Nessuna responsabilita',
|
|
'R': 'Responsible — esegue il lavoro',
|
|
'A': 'Accountable — approva e ne risponde',
|
|
'C': 'Consulted — viene consultato',
|
|
'I': 'Informed — viene informato'
|
|
};
|
|
window._policyRaciMap = window._policyRaciMap || {};
|
|
|
|
function canManageRaci() {
|
|
const role = (localStorage.getItem('nis2_user_role') || '').toLowerCase();
|
|
return ['super_admin', 'org_admin', 'compliance_manager'].includes(role);
|
|
}
|
|
|
|
async function loadPolicyRaci(policyId) {
|
|
const box = document.getElementById('policy-raci-' + policyId);
|
|
if (!box) return;
|
|
try {
|
|
const result = await api.raciMatrix();
|
|
if (!result || !result.success || !result.data) {
|
|
box.innerHTML = '<p style="color:var(--danger,#b91c1c);font-size:0.85rem;margin:0;">Impossibile caricare la matrice RACI.</p>';
|
|
return;
|
|
}
|
|
const roles = result.data.roles || [];
|
|
const map = {};
|
|
(result.data.assignments || []).forEach(a => {
|
|
if (a.object_type === 'procedure' && Number(a.object_id) === Number(policyId)) {
|
|
map[a.role_id] = a.raci;
|
|
}
|
|
});
|
|
window._policyRaciMap[policyId] = map;
|
|
|
|
if (!roles.length) {
|
|
box.innerHTML = '<p style="color:var(--gray-500);font-size:0.85rem;margin:0;">Nessun ruolo definito. Aggiungi i ruoli nell\'<a class="text-link" href="organigramma.html">organigramma</a> per assegnare la matrice RACI.</p>';
|
|
return;
|
|
}
|
|
const editable = canManageRaci();
|
|
const vals = Object.values(map);
|
|
const hasR = vals.includes('R');
|
|
const hasA = vals.includes('A');
|
|
const missing = [!hasR ? 'R' : null, !hasA ? 'A' : null].filter(Boolean);
|
|
const banner = (hasR && hasA)
|
|
? '<span class="badge badge-success">Requisiti di approvazione soddisfatti (R e A assegnate)</span>'
|
|
: '<span class="badge badge-warning">Per l\'approvazione manca: ' + missing.join(' e ') + '</span>';
|
|
|
|
let html = '<div style="margin-bottom:12px;">' + banner + '</div>';
|
|
html += '<div style="overflow-x:auto;"><table class="data-table" style="width:100%;border-collapse:collapse;">';
|
|
html += '<thead><tr><th style="text-align:left;">Ruolo (organigramma)</th>';
|
|
RACI_COLS.forEach(L => {
|
|
html += '<th style="text-align:center;min-width:48px;" title="' + RACI_TITLES[L] + '">' + (L === '' ? '—' : L) + '</th>';
|
|
});
|
|
html += '</tr></thead><tbody>';
|
|
roles.forEach(r => {
|
|
const cur = map[r.role_id] || '';
|
|
const rn = escapeHtml(r.role_name || ('Ruolo #' + r.role_id));
|
|
html += '<tr><td>' + rn + (r.is_governance_body ? ' <span class="badge badge-info" style="font-size:0.65rem;">organo di governo</span>' : '') + '</td>';
|
|
RACI_COLS.forEach(L => {
|
|
const checked = (cur === L) ? 'checked' : '';
|
|
const dis = editable ? '' : ' disabled';
|
|
html += '<td style="text-align:center;">'
|
|
+ '<input type="radio" name="raci-' + policyId + '-' + r.role_id + '" ' + checked + dis
|
|
+ ' onclick="setPolicyRaci(' + policyId + ',' + r.role_id + ',\'' + L + '\')"'
|
|
+ ' title="' + RACI_TITLES[L] + '"'
|
|
+ ' aria-label="' + RACI_TITLES[L] + ' per ' + rn + '"></td>';
|
|
});
|
|
html += '</tr>';
|
|
});
|
|
html += '</tbody></table></div>';
|
|
if (!editable) {
|
|
html += '<p style="font-size:0.78rem;color:var(--gray-500);margin:10px 0 0;">Solo Amministratore o Compliance Manager possono modificare la matrice RACI.</p>';
|
|
}
|
|
box.innerHTML = html;
|
|
} catch (e) {
|
|
box.innerHTML = '<p style="color:var(--danger,#b91c1c);font-size:0.85rem;margin:0;">Errore nel caricamento della matrice RACI.</p>';
|
|
}
|
|
}
|
|
|
|
async function setPolicyRaci(policyId, roleId, raci) {
|
|
if (!canManageRaci()) {
|
|
showNotification('Non hai i permessi per modificare la matrice RACI (richiede Amministratore o Compliance Manager).', 'warning');
|
|
loadPolicyRaci(policyId);
|
|
return;
|
|
}
|
|
const curMap = window._policyRaciMap[policyId] || {};
|
|
const cur = curMap[roleId] || '';
|
|
if (cur === raci) return; // no-op: stato gia' allineato
|
|
try {
|
|
let result;
|
|
if (raci === '') {
|
|
result = await api.raciUnassign({ role_id: roleId, object_type: 'procedure', object_id: policyId });
|
|
} else {
|
|
result = await api.raciAssign({ role_id: roleId, object_type: 'procedure', object_id: policyId, raci: raci });
|
|
}
|
|
if (result && result.success) {
|
|
showNotification('Matrice RACI aggiornata.', 'success');
|
|
} else {
|
|
showNotification((result && result.message) || 'Errore nell\'aggiornamento della matrice RACI.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
loadPolicyRaci(policyId);
|
|
}
|
|
|
|
// ── Create / Edit Modal ─────────────────────────────────
|
|
function openCreateModal() {
|
|
showPolicyFormModal(null);
|
|
}
|
|
|
|
async function openEditModal(id) {
|
|
try {
|
|
const result = await api.getPolicy(id);
|
|
if (result.success && result.data) {
|
|
showPolicyFormModal(result.data);
|
|
} else {
|
|
showNotification('Impossibile caricare la policy.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
}
|
|
|
|
function showPolicyFormModal(policy) {
|
|
const isEdit = !!policy;
|
|
const title = isEdit ? 'Modifica Policy' : 'Nuova Policy';
|
|
|
|
const categoryOptions = Object.entries(CATEGORY_LABELS).map(([val, label]) =>
|
|
`<option value="${val}" ${policy && policy.category === val ? 'selected' : ''}>${label}</option>`
|
|
).join('');
|
|
|
|
const content = `
|
|
<form id="policy-form" onsubmit="return false;">
|
|
<div class="form-group">
|
|
<label for="form-title" class="form-label">Titolo <span class="required">*</span></label>
|
|
<input type="text" class="form-input" id="form-title" value="${isEdit ? escapeHtml(policy.title) : ''}" required placeholder="Inserisci il titolo della policy">
|
|
</div>
|
|
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
|
<div class="form-group">
|
|
<label for="form-category" class="form-label">Categoria <span class="required">*</span></label>
|
|
<select class="form-select" id="form-category" required>
|
|
<option value="">Seleziona...</option>
|
|
${categoryOptions}
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="form-nis2-article" class="form-label">Requisito NIS2</label>
|
|
<input type="text" class="form-input" id="form-nis2-article" value="${isEdit && policy.nis2_article ? escapeHtml(policy.nis2_article) : ''}" placeholder="es. PR.PS-01">
|
|
</div>
|
|
</div>
|
|
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
|
<div class="form-group">
|
|
<label for="form-version" class="form-label">Versione</label>
|
|
<input type="text" class="form-input" id="form-version" value="${isEdit && policy.version ? escapeHtml(policy.version) : '1.0'}" placeholder="1.0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="form-review-date" class="form-label">Prossima Revisione</label>
|
|
<input type="date" class="form-input" id="form-review-date" value="${isEdit && policy.next_review_date ? policy.next_review_date.substring(0, 10) : ''}">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="form-content" class="form-label">Contenuto</label>
|
|
<textarea class="form-textarea" id="form-content" rows="12" placeholder="Inserisci il contenuto della policy... Puoi strutturare il documento con sezioni: 1. Scopo 2. Ambito 3. Responsabilita' 4. Procedure 5. Revisione">${isEdit && policy.content ? escapeHtml(policy.content) : ''}</textarea>
|
|
</div>
|
|
</form>
|
|
`;
|
|
|
|
showModal(title, content, {
|
|
size: 'lg',
|
|
footer: `
|
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
|
<button class="btn btn-primary" onclick="savePolicy(${isEdit ? policy.id : 'null'})">${isEdit ? 'Salva Modifiche' : 'Crea Policy'}</button>
|
|
`
|
|
});
|
|
}
|
|
|
|
async function savePolicy(id) {
|
|
const title = document.getElementById('form-title').value.trim();
|
|
const category = document.getElementById('form-category').value;
|
|
|
|
if (!title) {
|
|
showNotification('Il titolo e\' obbligatorio.', 'warning');
|
|
return;
|
|
}
|
|
if (!category) {
|
|
showNotification('La categoria e\' obbligatoria.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
title: title,
|
|
category: category,
|
|
nis2_article: document.getElementById('form-nis2-article').value.trim(),
|
|
version: document.getElementById('form-version').value.trim() || '1.0',
|
|
content: document.getElementById('form-content').value,
|
|
next_review_date: document.getElementById('form-review-date').value || null,
|
|
};
|
|
|
|
const btn = document.querySelector('#modal-overlay .btn-primary');
|
|
setButtonLoading(btn, true);
|
|
try {
|
|
let result;
|
|
if (id) {
|
|
result = await api.updatePolicy(id, data);
|
|
} else {
|
|
result = await api.createPolicy(data);
|
|
}
|
|
|
|
if (result.success) {
|
|
closeModal();
|
|
showNotification(id ? 'Policy aggiornata con successo.' : 'Policy creata con successo.', 'success');
|
|
loadPolicies();
|
|
if (currentView === 'detail' && id) {
|
|
viewPolicy(id);
|
|
}
|
|
} else {
|
|
setButtonLoading(btn, false);
|
|
showNotification(result.message || 'Errore nel salvataggio.', 'error');
|
|
}
|
|
} catch (e) {
|
|
setButtonLoading(btn, false);
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
}
|
|
|
|
// ── Approve Policy ──────────────────────────────────────
|
|
async function approvePolicy(id) {
|
|
showModal('Conferma Approvazione', `
|
|
<p>Sei sicuro di voler approvare questa policy?</p>
|
|
<p style="margin-top:8px;font-size:0.85rem;color:var(--gray-500);">Lo stato verra' modificato in "Approvata" e non sara' piu' possibile annullare l'operazione.</p>
|
|
`, {
|
|
size: 'sm',
|
|
footer: `
|
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
|
<button class="btn btn-success" onclick="doApprovePolicy(${id})">Approva</button>
|
|
`
|
|
});
|
|
}
|
|
|
|
async function doApprovePolicy(id) {
|
|
closeModal();
|
|
try {
|
|
const result = await api.approvePolicy(id);
|
|
if (result.success) {
|
|
showNotification('Policy approvata con successo.', 'success');
|
|
loadPolicies();
|
|
if (currentView === 'detail') {
|
|
viewPolicy(id);
|
|
}
|
|
} else {
|
|
showNotification(result.message || 'Errore nell\'approvazione.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
}
|
|
|
|
// ── Delete Policy ───────────────────────────────────────
|
|
function confirmDeletePolicy(id, title) {
|
|
showModal('Conferma Eliminazione', `
|
|
<p>Sei sicuro di voler eliminare la policy <strong>${title}</strong>?</p>
|
|
<p style="margin-top:8px;font-size:0.85rem;color:var(--danger);">Questa azione e' irreversibile.</p>
|
|
`, {
|
|
size: 'sm',
|
|
footer: `
|
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
|
<button class="btn btn-danger" onclick="doDeletePolicy(${id})">Elimina</button>
|
|
`
|
|
});
|
|
}
|
|
|
|
async function doDeletePolicy(id) {
|
|
closeModal();
|
|
try {
|
|
const result = await api.del(`/policies/${id}`);
|
|
if (result.success) {
|
|
showNotification('Policy eliminata.', 'success');
|
|
loadPolicies();
|
|
if (currentView === 'detail') {
|
|
backToList();
|
|
}
|
|
} else {
|
|
showNotification(result.message || 'Errore nell\'eliminazione.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
}
|
|
|
|
// ── AI Generate ─────────────────────────────────────────
|
|
function openAIGenerateModal() {
|
|
const categoryOptions = Object.entries(CATEGORY_LABELS).map(([val, label]) =>
|
|
`<option value="${val}">${label}</option>`
|
|
).join('');
|
|
|
|
showModal('Genera Policy con AI', `
|
|
<p style="margin-bottom:16px;color:var(--gray-600);font-size:0.9rem;">
|
|
Seleziona la categoria per generare automaticamente una bozza di policy tramite intelligenza artificiale.
|
|
Il contenuto generato potra' essere rivisto e modificato prima della pubblicazione.
|
|
</p>
|
|
<div class="form-group">
|
|
<label for="ai-category" class="form-label">Categoria Policy <span class="required">*</span></label>
|
|
<select class="form-select" id="ai-category">
|
|
${categoryOptions}
|
|
</select>
|
|
</div>
|
|
<div id="ai-preview-container" style="display:none;"></div>
|
|
`, {
|
|
footer: `
|
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
|
<button class="btn btn-primary" id="ai-generate-btn" onclick="doAIGenerate()">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
|
|
Genera
|
|
</button>
|
|
`
|
|
});
|
|
}
|
|
|
|
let aiGeneratedData = null;
|
|
|
|
async function doAIGenerate() {
|
|
const category = document.getElementById('ai-category').value;
|
|
const btn = document.getElementById('ai-generate-btn');
|
|
const previewContainer = document.getElementById('ai-preview-container');
|
|
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0 auto;"></div> Generazione...';
|
|
|
|
try {
|
|
const result = await api.aiGeneratePolicy(category);
|
|
if (result.success && result.data) {
|
|
aiGeneratedData = result.data;
|
|
previewContainer.style.display = 'block';
|
|
previewContainer.innerHTML = `
|
|
<div class="ai-preview-header">
|
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
|
|
Policy Generata: ${escapeHtml(aiGeneratedData.title || '')}
|
|
</div>
|
|
<div class="ai-preview">${escapeHtml(aiGeneratedData.content || '')}</div>
|
|
`;
|
|
|
|
// Change footer buttons
|
|
const footer = btn.closest('.modal-footer');
|
|
footer.innerHTML = `
|
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
|
<button class="btn btn-primary" onclick="saveAIGeneratedPolicy()">Salva come Bozza</button>
|
|
`;
|
|
} else {
|
|
showNotification(result.message || 'Errore nella generazione AI.', 'error');
|
|
btn.disabled = false;
|
|
btn.textContent = 'Genera';
|
|
}
|
|
} catch (e) {
|
|
showNotification('Errore di connessione.', 'error');
|
|
btn.disabled = false;
|
|
btn.textContent = 'Genera';
|
|
}
|
|
}
|
|
|
|
async function saveAIGeneratedPolicy() {
|
|
if (!aiGeneratedData) return;
|
|
|
|
const data = {
|
|
title: aiGeneratedData.title || 'Policy Generata AI',
|
|
category: aiGeneratedData.category || document.getElementById('ai-category')?.value || 'information_security',
|
|
nis2_article: aiGeneratedData.nis2_article || '',
|
|
content: aiGeneratedData.content || '',
|
|
version: '1.0',
|
|
ai_generated: 1,
|
|
};
|
|
|
|
const btn = document.querySelector('#modal-overlay .btn-primary');
|
|
setButtonLoading(btn, true);
|
|
try {
|
|
const result = await api.createPolicy(data);
|
|
if (result.success) {
|
|
closeModal();
|
|
showNotification('Policy AI salvata come bozza.', 'success');
|
|
aiGeneratedData = null;
|
|
loadPolicies();
|
|
} else {
|
|
setButtonLoading(btn, false);
|
|
showNotification(result.message || 'Errore nel salvataggio.', 'error');
|
|
}
|
|
} catch (e) {
|
|
setButtonLoading(btn, false);
|
|
showNotification('Errore di connessione.', 'error');
|
|
}
|
|
}
|
|
|
|
// ── Templates ───────────────────────────────────────────
|
|
async function loadTemplates() {
|
|
const container = document.getElementById('templates-grid');
|
|
container.innerHTML = '<div class="loading-overlay"><div class="spinner"></div><span>Caricamento template...</span></div>';
|
|
|
|
try {
|
|
const result = await api.getPolicyTemplates();
|
|
if (result.success && result.data) {
|
|
renderTemplates(result.data);
|
|
} else {
|
|
container.innerHTML = '<div class="empty-state"><h4>Nessun template disponibile</h4></div>';
|
|
}
|
|
} catch (e) {
|
|
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
|
|
}
|
|
}
|
|
|
|
function renderTemplates(templates) {
|
|
const container = document.getElementById('templates-grid');
|
|
|
|
if (!templates || templates.length === 0) {
|
|
container.innerHTML = '<div class="empty-state"><h4>Nessun template disponibile</h4></div>';
|
|
return;
|
|
}
|
|
|
|
let html = '<div class="template-grid">';
|
|
templates.forEach(t => {
|
|
const catLabel = CATEGORY_LABELS[t.category] || t.category;
|
|
html += `
|
|
<div class="template-card" onclick="createFromTemplate('${escapeHtml(t.category)}', '${escapeHtml(t.title).replace(/'/g, "\\'")}', '${escapeHtml(t.nis2_article || '').replace(/'/g, "\\'")}')">
|
|
<h4>${escapeHtml(t.title)}</h4>
|
|
<div class="template-meta">
|
|
<span class="tag">${escapeHtml(catLabel)}</span>
|
|
${t.nis2_article ? `<span style="color:var(--gray-400);">Art. ${escapeHtml(t.nis2_article)}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function createFromTemplate(category, title, nis2Article) {
|
|
showPolicyFormModal({
|
|
title: title,
|
|
category: category,
|
|
nis2_article: nis2Article,
|
|
version: '1.0',
|
|
content: '',
|
|
});
|
|
}
|
|
|
|
// ── Init ────────────────────────────────────────────────
|
|
loadPolicies().then(function () {
|
|
// Ticket #384 p.3 — deep-link da MISURE E REQUISITI: ?policy=ID apre il dettaglio della policy.
|
|
const pid = parseInt(new URLSearchParams(location.search).get('policy') || '', 10);
|
|
if (pid) viewPolicy(pid);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|