Porting del pattern TRPG (CHANGE_aria_voice_pointer.md) sulla sidebar V2: - public/js/aria-guide.js (nuovo): DESTINATIONS (26 voci menu → keyword IT), matchDestination(question), pointTo(nav,label) = overlay + spotlight pulsante + puntatore animato + coach-mark "Vai →"; Esc/click per chiudere; fallback navigate se la voce non è visibile; rispetta prefers-reduced-motion; self-contained, no inline-CSS. - common-bi.js: data-nav="<href senza .html>" sulle voci sidebar (aggancio puntatore). - common.js: inietta aria-guide.js dopo login; dopo ogni risposta ARIA, se la domanda combacia con una destinazione mostra il bottone "📍 Mostrami dov'è: <voce>". Frontend-only, additivo. Cache-buster common.js/common-bi.js -> 20260624f. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
497 lines
32 KiB
HTML
497 lines
32 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>Stakeholder - NIS2 Agile</title>
|
||
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. -->
|
||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||
<link rel="stylesheet" href="/css/style.css?v=20260618">
|
||
<style>
|
||
.stk-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
|
||
.stk-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
||
.stk-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
|
||
.stk-toolbar .grow { flex:1 1 auto; }
|
||
.stk-toolbar .btn { padding:5px 12px; font-size:.82rem; }
|
||
/* Matrice Mendelow */
|
||
.mx-card { border:1px solid var(--gray-200,#e5e7eb); border-radius:12px; padding:16px 18px; margin-bottom:18px; background:#fff; }
|
||
.mx-card h3 { margin:0 0 4px; font-size:1.02rem; }
|
||
.mx-sub { font-size:.8rem; color:var(--gray-500,#6b7280); margin-bottom:12px; }
|
||
.mx-wrap { display:flex; gap:20px; flex-wrap:wrap; align-items:flex-start; }
|
||
.mx-chart { flex:1 1 460px; min-width:300px; max-width:560px; }
|
||
.mx-side { flex:1 1 220px; min-width:200px; }
|
||
.mx-svg { width:100%; height:auto; display:block; }
|
||
.mx-dot { cursor:pointer; }
|
||
.mx-dot:focus { outline:2px solid #1d4ed8; outline-offset:2px; }
|
||
.mx-legend { display:flex; flex-direction:column; gap:6px; margin-top:6px; }
|
||
.mx-legend .it { display:flex; align-items:center; gap:8px; font-size:.8rem; }
|
||
.mx-sw { width:14px; height:14px; border-radius:4px; flex:0 0 auto; }
|
||
.mx-unrated { margin-top:6px; }
|
||
.mx-unrated h4 { font-size:.82rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); margin:0 0 6px; }
|
||
.mx-unrated ul { list-style:none; margin:0; padding:0; max-height:240px; overflow:auto; }
|
||
.mx-unrated li { font-size:.84rem; padding:5px 8px; border:1px solid var(--gray-100,#f3f4f6); border-radius:7px; margin-bottom:5px; cursor:pointer; display:flex; justify-content:space-between; gap:8px; }
|
||
.mx-unrated li:hover { background:var(--gray-50,#f9fafb); }
|
||
/* Registro */
|
||
.stk-group { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; margin-bottom:14px; overflow:hidden; }
|
||
.stk-group-head { background:var(--gray-50,#f9fafb); padding:11px 14px; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:6px; }
|
||
.stk-group-title { font-weight:700; font-size:.98rem; }
|
||
.stk-count { display:inline-block; font-size:.72rem; font-weight:800; padding:2px 10px; border-radius:20px; background:#e0f2fe; color:#075985; margin-left:8px; }
|
||
.stk-table { width:100%; border-collapse:collapse; font-size:.88rem; }
|
||
.stk-table th, .stk-table td { text-align:left; padding:9px 12px; border-top:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
|
||
.stk-table th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
|
||
.stk-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; white-space:nowrap; }
|
||
.stk-badge.code { background:#f1f5f9; color:#475569; font-family:monospace; }
|
||
.stk-badge.q1 { background:#fef3c7; color:#92400e; }
|
||
.stk-badge.q2 { background:#fee2e2; color:#991b1b; }
|
||
.stk-badge.q3 { background:#dbeafe; color:#1e40af; }
|
||
.stk-badge.q4 { background:#f3f4f6; color:#374151; }
|
||
.stk-badge.na { background:#f3f4f6; color:#9ca3af; }
|
||
.stk-pi { font-family:monospace; font-size:.82rem; white-space:nowrap; }
|
||
.stk-empty { text-align:center; padding:22px 16px; color:var(--gray-500,#6b7280); font-size:.86rem; }
|
||
.stk-link { font-size:.78rem; }
|
||
/* Modale */
|
||
.sm-overlay { display:none; position:fixed; inset:0; background:rgba(15,23,42,.5); z-index:1050; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 16px; }
|
||
.sm-overlay.open { display:flex; }
|
||
.sm-dialog { background:#fff; border-radius:12px; width:100%; max-width:600px; box-shadow:0 20px 50px rgba(0,0,0,.25); }
|
||
.sm-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
|
||
.sm-head h3 { margin:0; font-size:1.05rem; }
|
||
.sm-body { padding:18px 20px; }
|
||
.sm-foot { padding:14px 20px; border-top:1px solid var(--gray-100,#f3f4f6); display:flex; justify-content:flex-end; gap:10px; }
|
||
.sm-field { margin-bottom:14px; }
|
||
.sm-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; }
|
||
.sm-field .hint { font-weight:400; color:var(--gray-500,#6b7280); font-size:.78rem; }
|
||
.sm-field input[type=text], .sm-field input[type=email], .sm-field select, .sm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; background:#fff; }
|
||
.sm-field select[multiple] { min-height:108px; }
|
||
.sm-field textarea { min-height:60px; resize:vertical; }
|
||
.sm-row { display:flex; gap:12px; flex-wrap:wrap; }
|
||
.sm-row > .sm-field { flex:1 1 0; min-width:140px; }
|
||
.sm-err { color:#b91c1c; font-size:.82rem; min-height:18px; }
|
||
.sm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); }
|
||
.hidden { display:none !important; }
|
||
</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>
|
||
<div class="app-layout">
|
||
<aside class="sidebar" id="sidebar"></aside>
|
||
<main class="main-content">
|
||
<header class="content-header">
|
||
<h2 data-i18n="stk.title">Stakeholder</h2>
|
||
<div class="content-header-actions">
|
||
<button class="btn btn-outline btn-sm" onclick="stkTypeOpen()">+ Aggiungi tipo</button>
|
||
<button class="btn btn-primary btn-sm" onclick="stkOpen()">+ Aggiungi stakeholder</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="content-body">
|
||
<div class="stk-intro">
|
||
<strong>Mappatura e gestione degli stakeholder.</strong>
|
||
Registra gli stakeholder interni ed esterni scegliendo il tipo dall'elenco (puoi aggiungerne di nuovi),
|
||
collegali all'organigramma (interni) o a un fornitore della Supply Chain (esterni) e alle procedure,
|
||
e valuta <em>potere</em> e <em>interesse</em> (0–5): il sistema li colloca automaticamente sulla matrice.
|
||
</div>
|
||
<div class="stk-note">
|
||
Ancoraggio: <strong>GV.SC-02</strong> (ruoli e responsabilità verso fornitori, clienti e partner) —
|
||
obbligo di gestione del rischio della catena di fornitura ai sensi dell'<strong>art. 24 D.Lgs. 138/2024</strong>.
|
||
La <strong>matrice di Mendelow</strong> (potere/interesse) è una <strong>buona prassi</strong> di gestione,
|
||
<strong>non un obbligo NIS2</strong>. Strumento di supporto, non un parere legale.
|
||
</div>
|
||
|
||
<div class="stk-toolbar">
|
||
<span class="grow"></span>
|
||
<a class="btn btn-outline btn-sm" href="supply-chain.html">Gestisci fornitori in Supply Chain</a>
|
||
</div>
|
||
|
||
<!-- Matrice di Mendelow -->
|
||
<div class="mx-card" id="mx-card">
|
||
<h3>Matrice di Mendelow <span style="font-weight:400;font-size:.8rem;color:#6b7280;">(buona prassi · x = interesse, y = potere)</span></h3>
|
||
<div class="mx-sub">Ogni stakeholder valutato è collocato nel quadrante in base a potere e interesse. Clicca un punto per modificarlo.</div>
|
||
<div class="mx-wrap">
|
||
<div class="mx-chart" id="mx-chart"></div>
|
||
<div class="mx-side">
|
||
<div class="mx-legend" id="mx-legend"></div>
|
||
<div class="mx-unrated" id="mx-unrated"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="stk-groups" aria-live="polite"></div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- Modale stakeholder (crea/modifica) -->
|
||
<div class="sm-overlay" id="stk-modal" role="dialog" aria-modal="true" aria-labelledby="stk-modal-title">
|
||
<div class="sm-dialog">
|
||
<div class="sm-head"><h3 id="stk-modal-title">Aggiungi stakeholder</h3><button class="sm-close" type="button" onclick="stkClose()" aria-label="Chiudi">×</button></div>
|
||
<div class="sm-body">
|
||
<input type="hidden" id="stk-id">
|
||
<div class="sm-row">
|
||
<div class="sm-field"><label for="stk-f-type">Tipo stakeholder *</label>
|
||
<select id="stk-f-type" onchange="stkOnTypeChange()"></select>
|
||
</div>
|
||
<div class="sm-field"><label for="stk-f-name">Nome / denominazione *</label><input type="text" id="stk-f-name" maxlength="255" placeholder="es. Mario Rossi · Acme S.p.A."></div>
|
||
</div>
|
||
<div class="sm-field" id="stk-wrap-role">
|
||
<label for="stk-f-role">Collegamento all'organigramma <span class="hint">(interni)</span></label>
|
||
<select id="stk-f-role"><option value="">— Nessuno —</option></select>
|
||
</div>
|
||
<div class="sm-field hidden" id="stk-wrap-supplier">
|
||
<label for="stk-f-supplier">Collegamento a un fornitore <span class="hint">(esterni · dalla Supply Chain)</span></label>
|
||
<select id="stk-f-supplier"><option value="">— Nessuno —</option></select>
|
||
</div>
|
||
<div class="sm-row">
|
||
<div class="sm-field"><label for="stk-f-power">Potere sull'azienda (0–5)</label>
|
||
<select id="stk-f-power"></select>
|
||
</div>
|
||
<div class="sm-field"><label for="stk-f-interest">Interesse sull'azienda (0–5)</label>
|
||
<select id="stk-f-interest"></select>
|
||
</div>
|
||
</div>
|
||
<div class="sm-field">
|
||
<label for="stk-f-procs">Procedure collegate <span class="hint">(Ctrl/Cmd per selezione multipla)</span></label>
|
||
<select id="stk-f-procs" multiple></select>
|
||
</div>
|
||
<div class="sm-row">
|
||
<div class="sm-field"><label for="stk-f-contact-name">Nome contatto</label><input type="text" id="stk-f-contact-name" maxlength="255"></div>
|
||
<div class="sm-field"><label for="stk-f-contact-email">Email contatto</label><input type="email" id="stk-f-contact-email" maxlength="255" placeholder="referente@esempio.it"></div>
|
||
</div>
|
||
<div class="sm-field"><label for="stk-f-notes">Note</label><textarea id="stk-f-notes" maxlength="2000"></textarea></div>
|
||
<div class="sm-err" id="stk-err" role="alert"></div>
|
||
</div>
|
||
<div class="sm-foot"><button class="btn btn-outline" type="button" onclick="stkClose()">Annulla</button><button class="btn btn-primary" type="button" id="stk-save" onclick="stkSave()">Salva</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modale aggiungi tipo -->
|
||
<div class="sm-overlay" id="stk-type-modal" role="dialog" aria-modal="true" aria-labelledby="stk-type-title">
|
||
<div class="sm-dialog" style="max-width:480px;">
|
||
<div class="sm-head"><h3 id="stk-type-title">Aggiungi tipo stakeholder</h3><button class="sm-close" type="button" onclick="stkTypeClose()" aria-label="Chiudi">×</button></div>
|
||
<div class="sm-body">
|
||
<p style="font-size:.84rem;color:#6b7280;margin:0 0 12px;">Il nuovo tipo riceverà automaticamente il codice successivo (proseguendo da <code>Stak.30</code>). I 30 tipi di sistema non sono modificabili.</p>
|
||
<div class="sm-field"><label for="stk-t-tipo">Categoria *</label>
|
||
<select id="stk-t-tipo"><option value="Interno">Interno</option><option value="Esterno" selected>Esterno</option></select>
|
||
</div>
|
||
<div class="sm-field"><label for="stk-t-descr">Descrizione *</label><textarea id="stk-t-descr" maxlength="1000" placeholder="es. Comunità accademica: università e centri di ricerca partner."></textarea></div>
|
||
<div class="sm-err" id="stk-t-err" role="alert"></div>
|
||
</div>
|
||
<div class="sm-foot"><button class="btn btn-outline" type="button" onclick="stkTypeClose()">Annulla</button><button class="btn btn-primary" type="button" id="stk-t-save" onclick="stkTypeSave()">Salva</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/js/api.js?v=20260631"></script>
|
||
<script src="/js/common.js?v=20260624f"></script>
|
||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||
<script src="/js/common-bi.js?v=20260624f"></script>
|
||
<script src="/js/i18n.js?v=20260631"></script>
|
||
<script src="/js/help.js?v=20260624e"></script>
|
||
<script>
|
||
'use strict';
|
||
/*
|
||
* NIS2 Agile - Registro Stakeholder + matrice di Mendelow (Epic C / C5.1)
|
||
* Tabella dedicata `stakeholders` (NON suppliers): interni -> org_roles,
|
||
* esterni -> link opzionale a un supplier. Doppia valutazione potere/interesse
|
||
* 0-5 -> quadrante calcolato lato API (cfg_stakeholder_quadrants). Procedure m2m.
|
||
* Ancoraggio GV.SC-02 (obbligo). Matrice di Mendelow = buona prassi, non obbligo.
|
||
*/
|
||
let STK = { list: [], quadrants: [], types: [], pickers: { org_roles: [], policies: [], suppliers: [] } };
|
||
|
||
// Stile per quadrante (codici dal seed: Q1 keep satisfied, Q2 manage closely, Q3 keep informed, Q4 monitor)
|
||
const QSTYLE = {
|
||
Q1: { fill:'#fef3c7', stroke:'#f59e0b', dot:'#d97706', cls:'q1' },
|
||
Q2: { fill:'#fee2e2', stroke:'#ef4444', dot:'#dc2626', cls:'q2' },
|
||
Q3: { fill:'#dbeafe', stroke:'#3b82f6', dot:'#2563eb', cls:'q3' },
|
||
Q4: { fill:'#f3f4f6', stroke:'#9ca3af', dot:'#6b7280', cls:'q4' },
|
||
};
|
||
|
||
function el(id) { return document.getElementById(id); }
|
||
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
||
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
|
||
|
||
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();
|
||
|
||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { stkClose(); stkTypeClose(); } });
|
||
['stk-modal','stk-type-modal'].forEach(function (mid) {
|
||
const ov = el(mid);
|
||
if (ov) ov.addEventListener('click', function (e) { if (e.target === ov) ov.classList.remove('open'); });
|
||
});
|
||
|
||
// Popola le select dei punteggi 0-5
|
||
['stk-f-power','stk-f-interest'].forEach(function (sid) {
|
||
let html = '<option value="">— non valutato —</option>';
|
||
for (let i = 0; i <= 5; i++) html += '<option value="' + i + '">' + i + '</option>';
|
||
el(sid).innerHTML = html;
|
||
});
|
||
|
||
await stkLoadAll();
|
||
});
|
||
|
||
async function stkLoadAll() {
|
||
el('stk-groups').innerHTML = '<div class="stk-empty">Caricamento…</div>';
|
||
try {
|
||
const [list, types, pickers] = await Promise.all([api.stkList(), api.stkTypes(), api.stkPickers()]);
|
||
STK.list = (list && list.stakeholders) || [];
|
||
STK.quadrants = (list && list.quadrants) || [];
|
||
STK.types = (types && types.types) || [];
|
||
STK.pickers = (pickers) || { org_roles: [], policies: [], suppliers: [] };
|
||
stkFillFormSelects();
|
||
renderMatrix();
|
||
renderRegistry();
|
||
} catch (e) {
|
||
el('stk-groups').innerHTML = '<div class="stk-empty">Errore nel caricamento: ' + esc(e.message || e) + '</div>';
|
||
}
|
||
}
|
||
|
||
// ── Selettori del form (tipo / organigramma / fornitori / procedure) ──
|
||
function stkFillFormSelects() {
|
||
// Tipo (optgroup Interni/Esterni)
|
||
const interni = STK.types.filter(t => t.tipo === 'Interno');
|
||
const esterni = STK.types.filter(t => t.tipo === 'Esterno');
|
||
const optsFor = arr => arr.map(t => '<option value="' + escAttr(t.code) + '">' + esc(t.code) + ' — ' + esc((t.descr || '').slice(0, 70)) + '</option>').join('');
|
||
el('stk-f-type').innerHTML =
|
||
'<optgroup label="Interni">' + optsFor(interni) + '</optgroup>' +
|
||
'<optgroup label="Esterni">' + optsFor(esterni) + '</optgroup>';
|
||
|
||
el('stk-f-role').innerHTML = '<option value="">— Nessuno —</option>' +
|
||
(STK.pickers.org_roles || []).map(r => '<option value="' + r.id + '">' + esc(r.role_name) + '</option>').join('');
|
||
el('stk-f-supplier').innerHTML = '<option value="">— Nessuno —</option>' +
|
||
(STK.pickers.suppliers || []).map(s => '<option value="' + s.id + '">' + esc(s.name) + '</option>').join('');
|
||
el('stk-f-procs').innerHTML =
|
||
(STK.pickers.policies || []).map(p => '<option value="' + p.id + '">' + esc(p.title) + '</option>').join('') ||
|
||
'<option value="" disabled>Nessuna procedura disponibile</option>';
|
||
}
|
||
|
||
function tipoOfCode(code) {
|
||
const t = STK.types.find(x => x.code === code);
|
||
return t ? t.tipo : null;
|
||
}
|
||
|
||
// ── Matrice di Mendelow (SVG dipendency-free) ──
|
||
function renderMatrix() {
|
||
const PADX = 64, PADTOP = 16, S = 360, W = PADX + S + 24, H = PADTOP + S + 52;
|
||
const quads = STK.quadrants.length ? STK.quadrants
|
||
: [{code:'Q1',power_min:3,power_max:5,interest_min:0,interest_max:2},
|
||
{code:'Q2',power_min:3,power_max:5,interest_min:3,interest_max:5},
|
||
{code:'Q3',power_min:0,power_max:2,interest_min:3,interest_max:5},
|
||
{code:'Q4',power_min:0,power_max:2,interest_min:0,interest_max:2}];
|
||
|
||
const px = v => PADX + (v / 5) * S; // interesse -> x
|
||
const py = v => PADTOP + (1 - v / 5) * S; // potere -> y (invertito)
|
||
|
||
let svg = '<svg class="mx-svg" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="Matrice di Mendelow potere/interesse">';
|
||
|
||
// Rettangoli dei quadranti
|
||
quads.forEach(function (q) {
|
||
const x0 = q.interest_min <= 2 ? PADX : PADX + S / 2;
|
||
const x1 = q.interest_max <= 2 ? PADX + S / 2 : PADX + S;
|
||
const y0 = q.power_max >= 3 ? PADTOP : PADTOP + S / 2; // alto
|
||
const y1 = q.power_min >= 3 ? PADTOP + S / 2 : PADTOP + S; // basso
|
||
const st = QSTYLE[q.code] || QSTYLE.Q4;
|
||
svg += '<rect x="' + x0 + '" y="' + y0 + '" width="' + (x1 - x0) + '" height="' + (y1 - y0) + '" fill="' + st.fill + '" stroke="' + st.stroke + '" stroke-width="1" opacity="0.55"/>';
|
||
const lbl = (q.label || q.code).replace(/\s*\(.*\)/, '');
|
||
svg += '<text x="' + (x0 + 8) + '" y="' + (y0 + 16) + '" font-size="11" font-weight="700" fill="#374151">' + esc(q.code) + '</text>';
|
||
svg += '<text x="' + (x0 + 8) + '" y="' + (y0 + 30) + '" font-size="10" fill="#4b5563">' + esc(lbl.slice(0, 26)) + '</text>';
|
||
});
|
||
|
||
// Assi
|
||
svg += '<line x1="' + PADX + '" y1="' + PADTOP + '" x2="' + PADX + '" y2="' + (PADTOP + S) + '" stroke="#9ca3af" stroke-width="1.5"/>';
|
||
svg += '<line x1="' + PADX + '" y1="' + (PADTOP + S) + '" x2="' + (PADX + S) + '" y2="' + (PADTOP + S) + '" stroke="#9ca3af" stroke-width="1.5"/>';
|
||
// Etichette assi
|
||
svg += '<text x="' + (PADX + S / 2) + '" y="' + (PADTOP + S + 40) + '" font-size="12" font-weight="700" fill="#374151" text-anchor="middle">Interesse →</text>';
|
||
svg += '<text transform="translate(16,' + (PADTOP + S / 2) + ') rotate(-90)" font-size="12" font-weight="700" fill="#374151" text-anchor="middle">Potere →</text>';
|
||
svg += '<text x="' + PADX + '" y="' + (PADTOP + S + 18) + '" font-size="10" fill="#6b7280" text-anchor="middle">0</text>';
|
||
svg += '<text x="' + (PADX + S) + '" y="' + (PADTOP + S + 18) + '" font-size="10" fill="#6b7280" text-anchor="middle">5</text>';
|
||
svg += '<text x="' + (PADX - 10) + '" y="' + (PADTOP + S) + '" font-size="10" fill="#6b7280" text-anchor="end">0</text>';
|
||
svg += '<text x="' + (PADX - 10) + '" y="' + (PADTOP + 6) + '" font-size="10" fill="#6b7280" text-anchor="end">5</text>';
|
||
|
||
// Punti (con jitter deterministico per celle affollate)
|
||
const rated = STK.list.filter(s => s.rated);
|
||
const cellCount = {}, cellIdx = {};
|
||
rated.forEach(s => { const k = s.power + '_' + s.interest; cellCount[k] = (cellCount[k] || 0) + 1; });
|
||
rated.forEach(function (s) {
|
||
const k = s.power + '_' + s.interest;
|
||
const n = cellCount[k]; const i = (cellIdx[k] = (cellIdx[k] || 0) + 1) - 1;
|
||
let ox = 0, oy = 0;
|
||
if (n > 1) { const ang = (2 * Math.PI * i) / n; const rad = 9; ox = Math.cos(ang) * rad; oy = Math.sin(ang) * rad; }
|
||
const cx = px(s.interest) + ox, cy = py(s.power) + oy;
|
||
const st = QSTYLE[s.quadrant_code] || QSTYLE.Q4;
|
||
const tip = s.name + ' (' + s.stak_code + ') · Potere ' + s.power + ' / Interesse ' + s.interest + ' · ' + (s.quadrant_label || '');
|
||
svg += '<circle class="mx-dot" tabindex="0" role="button" cx="' + cx.toFixed(1) + '" cy="' + cy.toFixed(1) + '" r="7" fill="' + st.dot + '" stroke="#fff" stroke-width="1.5" '
|
||
+ 'onclick="stkOpen(' + s.id + ')" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();stkOpen(' + s.id + ');}">'
|
||
+ '<title>' + escAttr(tip) + '</title></circle>';
|
||
});
|
||
|
||
svg += '</svg>';
|
||
el('mx-chart').innerHTML = svg;
|
||
|
||
// Legenda
|
||
el('mx-legend').innerHTML = (STK.quadrants.length ? STK.quadrants : quads).map(function (q) {
|
||
const st = QSTYLE[q.code] || QSTYLE.Q4;
|
||
const n = STK.list.filter(s => s.quadrant_code === q.code).length;
|
||
return '<div class="it"><span class="mx-sw" style="background:' + st.dot + '"></span><span><strong>' + esc(q.code) + '</strong> ' + esc((q.label || '')) + ' · ' + n + '</span></div>';
|
||
}).join('');
|
||
|
||
// Non valutati
|
||
const unrated = STK.list.filter(s => !s.rated);
|
||
el('mx-unrated').innerHTML = unrated.length
|
||
? '<h4>Da valutare (' + unrated.length + ')</h4><ul>' + unrated.map(s =>
|
||
'<li onclick="stkOpen(' + s.id + ')"><span>' + esc(s.name) + '</span><span class="stk-badge code">' + esc(s.stak_code) + '</span></li>').join('') + '</ul>'
|
||
: '';
|
||
}
|
||
|
||
// ── Registro per categoria ──
|
||
function renderRegistry() {
|
||
if (!STK.list.length) {
|
||
el('stk-groups').innerHTML = '<div class="stk-empty">Nessuno stakeholder registrato. Aggiungine uno con "+ Aggiungi stakeholder".</div>';
|
||
return;
|
||
}
|
||
const groups = [
|
||
{ tipo: 'Interno', title: 'Stakeholder interni' },
|
||
{ tipo: 'Esterno', title: 'Stakeholder esterni' },
|
||
];
|
||
el('stk-groups').innerHTML = groups.map(function (g) {
|
||
const items = STK.list.filter(s => s.tipo === g.tipo);
|
||
let inner;
|
||
if (!items.length) {
|
||
inner = '<div class="stk-empty">Nessuno stakeholder ' + (g.tipo === 'Interno' ? 'interno' : 'esterno') + ' registrato.</div>';
|
||
} else {
|
||
const rows = items.map(function (s) {
|
||
const q = s.quadrant_code ? '<span class="stk-badge ' + (QSTYLE[s.quadrant_code] || QSTYLE.Q4).cls + '">' + esc(s.quadrant_code) + ' · ' + esc((s.quadrant_label || '').replace(/\s*\(.*\)/, '')) + '</span>' : '<span class="stk-badge na">da valutare</span>';
|
||
const pi = s.rated ? '<span class="stk-pi">P' + s.power + ' · I' + s.interest + '</span>' : '<span class="stk-pi" style="color:#9ca3af">—</span>';
|
||
const link = s.org_role_name ? ('👤 ' + esc(s.org_role_name)) : (s.supplier_name ? ('🏢 ' + esc(s.supplier_name)) : '—');
|
||
const procs = (s.policy_ids && s.policy_ids.length) ? (s.policy_ids.length + ' proc.') : '—';
|
||
return '<tr>'
|
||
+ '<td><span class="stk-badge code">' + esc(s.stak_code) + '</span></td>'
|
||
+ '<td><strong>' + esc(s.name) + '</strong>'
|
||
+ (s.contact_name || s.contact_email ? '<div style="font-size:.78rem;color:#6b7280;margin-top:2px;">' + esc(s.contact_name || '') + (s.contact_email ? ' · ' + esc(s.contact_email) : '') + '</div>' : '') + '</td>'
|
||
+ '<td>' + pi + '</td>'
|
||
+ '<td>' + q + '</td>'
|
||
+ '<td style="font-size:.82rem;">' + link + '</td>'
|
||
+ '<td style="font-size:.82rem;">' + procs + '</td>'
|
||
+ '<td style="white-space:nowrap;">'
|
||
+ '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="stkOpen(' + s.id + ')">Modifica</button> '
|
||
+ '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;color:#b91c1c;border-color:#fecaca;" onclick="stkDelete(' + s.id + ')">Elimina</button>'
|
||
+ '</td></tr>';
|
||
}).join('');
|
||
inner = '<table class="stk-table"><thead><tr>'
|
||
+ '<th>Codice</th><th>Nome</th><th>Pot./Int.</th><th>Quadrante</th><th>Collegamento</th><th>Procedure</th><th>Azioni</th>'
|
||
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
|
||
}
|
||
return '<div class="stk-group"><div class="stk-group-head"><div class="stk-group-title">' + esc(g.title)
|
||
+ '<span class="stk-count">' + items.length + '</span></div></div>' + inner + '</div>';
|
||
}).join('');
|
||
}
|
||
|
||
// ── Modale stakeholder ──
|
||
function stkOpen(id) {
|
||
const s = id ? STK.list.find(x => x.id === id) : null;
|
||
el('stk-modal-title').textContent = s ? 'Modifica stakeholder' : 'Aggiungi stakeholder';
|
||
el('stk-id').value = s ? s.id : '';
|
||
el('stk-f-type').value = s ? s.stak_code : (el('stk-f-type').querySelector('option') ? el('stk-f-type').querySelector('option').value : '');
|
||
el('stk-f-name').value = s ? (s.name || '') : '';
|
||
el('stk-f-role').value = s && s.org_role_id ? String(s.org_role_id) : '';
|
||
el('stk-f-supplier').value = s && s.supplier_id ? String(s.supplier_id) : '';
|
||
el('stk-f-power').value = s && s.power != null ? String(s.power) : '';
|
||
el('stk-f-interest').value = s && s.interest != null ? String(s.interest) : '';
|
||
el('stk-f-contact-name').value = s ? (s.contact_name || '') : '';
|
||
el('stk-f-contact-email').value = s ? (s.contact_email || '') : '';
|
||
el('stk-f-notes').value = s ? (s.notes || '') : '';
|
||
// procedure multi-select
|
||
const sel = el('stk-f-procs'); const have = (s && s.policy_ids) || [];
|
||
Array.prototype.forEach.call(sel.options, o => { o.selected = have.indexOf(parseInt(o.value, 10)) !== -1; });
|
||
el('stk-err').textContent = '';
|
||
stkOnTypeChange();
|
||
el('stk-modal').classList.add('open');
|
||
setTimeout(() => el('stk-f-name').focus(), 50);
|
||
}
|
||
function stkClose() { el('stk-modal').classList.remove('open'); }
|
||
|
||
// Mostra organigramma per interni, fornitore per esterni
|
||
function stkOnTypeChange() {
|
||
const tipo = tipoOfCode(el('stk-f-type').value);
|
||
const isInt = tipo === 'Interno';
|
||
el('stk-wrap-role').classList.toggle('hidden', !isInt);
|
||
el('stk-wrap-supplier').classList.toggle('hidden', isInt);
|
||
if (isInt) el('stk-f-supplier').value = ''; else el('stk-f-role').value = '';
|
||
}
|
||
|
||
async function stkSave() {
|
||
const id = el('stk-id').value;
|
||
const code = el('stk-f-type').value;
|
||
const name = el('stk-f-name').value.trim();
|
||
if (!code) { el('stk-err').textContent = 'Seleziona un tipo.'; return; }
|
||
if (!name) { el('stk-err').textContent = 'Il nome è obbligatorio.'; return; }
|
||
const tipo = tipoOfCode(code);
|
||
const procs = Array.prototype.filter.call(el('stk-f-procs').options, o => o.selected).map(o => parseInt(o.value, 10)).filter(Boolean);
|
||
const payload = {
|
||
stak_code: code,
|
||
name: name,
|
||
org_role_id: tipo === 'Interno' && el('stk-f-role').value ? parseInt(el('stk-f-role').value, 10) : null,
|
||
supplier_id: tipo === 'Esterno' && el('stk-f-supplier').value ? parseInt(el('stk-f-supplier').value, 10) : null,
|
||
power: el('stk-f-power').value !== '' ? parseInt(el('stk-f-power').value, 10) : null,
|
||
interest: el('stk-f-interest').value !== '' ? parseInt(el('stk-f-interest').value, 10) : null,
|
||
policy_ids: procs,
|
||
contact_name: el('stk-f-contact-name').value.trim(),
|
||
contact_email: el('stk-f-contact-email').value.trim(),
|
||
notes: el('stk-f-notes').value.trim(),
|
||
};
|
||
const btn = el('stk-save'); btn.disabled = true;
|
||
try {
|
||
if (id) await api.stkUpdate(parseInt(id, 10), payload); else await api.stkCreate(payload);
|
||
showNotification(id ? 'Stakeholder aggiornato.' : 'Stakeholder aggiunto.', 'success');
|
||
stkClose();
|
||
await stkLoadAll();
|
||
} catch (e) {
|
||
el('stk-err').textContent = (e && e.message) ? e.message : 'Errore nel salvataggio.';
|
||
} finally { btn.disabled = false; }
|
||
}
|
||
|
||
async function stkDelete(id) {
|
||
const s = STK.list.find(x => x.id === id);
|
||
if (!confirm('Eliminare lo stakeholder "' + (s ? s.name : '') + '"?')) return;
|
||
try {
|
||
await api.stkDelete(id);
|
||
showNotification('Stakeholder eliminato.', 'success');
|
||
await stkLoadAll();
|
||
} catch (e) { showNotification((e && e.message) || 'Errore nell\'eliminazione.', 'error'); }
|
||
}
|
||
|
||
// ── Modale aggiungi tipo ──
|
||
function stkTypeOpen() { el('stk-t-err').textContent = ''; el('stk-t-descr').value = ''; el('stk-type-modal').classList.add('open'); setTimeout(() => el('stk-t-descr').focus(), 50); }
|
||
function stkTypeClose() { el('stk-type-modal').classList.remove('open'); }
|
||
async function stkTypeSave() {
|
||
const tipo = el('stk-t-tipo').value;
|
||
const descr = el('stk-t-descr').value.trim();
|
||
if (!descr) { el('stk-t-err').textContent = 'La descrizione è obbligatoria.'; return; }
|
||
const btn = el('stk-t-save'); btn.disabled = true;
|
||
try {
|
||
const r = await api.stkAddType({ tipo: tipo, descr: descr });
|
||
showNotification('Tipo aggiunto: ' + (r && r.code ? r.code : ''), 'success');
|
||
stkTypeClose();
|
||
const types = await api.stkTypes();
|
||
STK.types = (types && types.types) || STK.types;
|
||
stkFillFormSelects();
|
||
} catch (e) {
|
||
el('stk-t-err').textContent = (e && e.message) ? e.message : 'Errore nel salvataggio.';
|
||
} finally { btn.disabled = false; }
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|