[FEAT] A4 Fase 4.2 — Competenze (skill/requisiti ruolo/gap) + alert→azione NCR+CAPA
Secondo modulo del modello relazionale A4 (docs/DESIGN_A4_RELATIONAL.md). Backend: - Migration 042 (docs/sql/042_competences.sql) + runner aggiornato (scripts/migrate-a4.php). 4 tabelle additive/idempotenti: skills (org-owned o globali), role_skills (competenze richieste dal ruolo, UNIQUE role+skill), user_skills (possedute, UNIQUE org+user+skill), skill_course_map. APPLICATA su prod (nis2-db v8.0.45, TLSv1.3). - CompetenceController (route 'competences'): catalog, skills CRUD, roleSkills, userSkills, skillCourses (map/unmap), gapGrid (richiesto−posseduto per ruolo con titolare), openAction. Multi-tenancy + anti-IDOR + livelli 1-5; competenze globali in sola lettura per gli utenti org. - alert→azione: openAction RIUSA il workflow NCR/CAPA reale (la tabella azioni è capa_actions figlia di non_conformities, NON 'corrective_actions' che non esiste): crea NCR (source='management_review', source_entity_type='competence_gap', source_entity_id=role_skills.id) + capa_actions figlia. Anti-duplicato sul gap. Zero ALTER alle tabelle esistenti. Ancoraggio PR.AT-01/02, GV.RR-04, art.24 D.Lgs. Frontend: - competenze.html (4 schede: Catalogo, Requisiti per ruolo, Competenze persone, Gap & azioni) + js/competenze.js. Bootstrap Italia V2. CTA "Assegna corso" (riuso /training/assign) e "Apri non conformità". - Voce sidebar "Competenze" (common.js + common-bi.js + BI_PAGES) + nav.competences i18n IT/EN. api.js: metodi comp* + assignTraining. Help/KB/PWA: - help.js: guida contestuale 'competences' (schede, calcolo gap, PR.AT-01/02, GV.RR-04, art.24 D.Lgs., disclaimer no-parere-legale) + mappa pagina. - sw.js: nome cache nis2-shell-v1.17.0 (allineamento PWA). - Design doc corretto (rettifica capa_actions vs corrective_actions) + avanzamento 4.1/4.2. Cache-buster: ?v=20260618 dei 5 JS condivisi su 32 HTML. version.json 1.16.0 -> 1.17.0. Smoke E2E su prod (fpm reale): catalogo, requisito, competenza, gap=2, openAction (NCR+CAPA), anti-dup 409, mappatura corso — tutti verdi; dati di test ripuliti. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
47199f1e4e
commit
5368b0a61c
@@ -235,6 +235,25 @@ class NIS2API {
|
||||
orgRoleUpdate(id, data) { return this._acn(this.put(`/org-roles/${id}`, data)); }
|
||||
orgRoleDelete(id) { return this._acn(this.del(`/org-roles/${id}`)); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Competenze (A4 Fase 4.2) — skill / requisiti ruolo / competenze utente /
|
||||
// mappatura corsi / gap competenze / apertura azione (NCR+CAPA). _acn.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
compCatalog() { return this._acn(this.get('/competences/catalog')); }
|
||||
compCreateSkill(data) { return this._acn(this.post('/competences/skills', data || {})); }
|
||||
compUpdateSkill(id, data) { return this._acn(this.put(`/competences/skills/${id}`, data)); }
|
||||
compDeleteSkill(id) { return this._acn(this.del(`/competences/skills/${id}`)); }
|
||||
compRoleSkills(roleId) { return this._acn(this.get(`/competences/role-skills/${roleId}`)); }
|
||||
compSetRoleSkill(data) { return this._acn(this.post('/competences/role-skills', data)); }
|
||||
compRemoveRoleSkill(id) { return this._acn(this.del(`/competences/role-skills/${id}`)); }
|
||||
compUserSkills(userId) { return this._acn(this.get(`/competences/user-skills/${userId}`)); }
|
||||
compSetUserSkill(data) { return this._acn(this.post('/competences/user-skills', data)); }
|
||||
compRemoveUserSkill(id) { return this._acn(this.del(`/competences/user-skills/${id}`)); }
|
||||
compMapCourse(data) { return this._acn(this.post('/competences/skill-courses', data)); }
|
||||
compUnmapCourse(id) { return this._acn(this.del(`/competences/skill-courses/${id}`)); }
|
||||
compGapGrid() { return this._acn(this.get('/competences/gap-grid')); }
|
||||
compOpenAction(roleSkillId) { return this._acn(this.post('/competences/open-action', { role_skill_id: roleSkillId })); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Dashboard
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
@@ -308,6 +327,7 @@ class NIS2API {
|
||||
listCourses() { return this.get('/training/courses'); }
|
||||
getMyTraining() { return this.get('/training/assignments'); }
|
||||
getTrainingCompliance() { return this.get('/training/compliance-status'); }
|
||||
assignTraining(courseId, userIds, dueDate) { return this.post('/training/assign', { course_id: courseId, user_ids: userIds, due_date: dueDate || null }); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Assets
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
'reports.html': 'reports.html',
|
||||
'settings.html': 'settings.html',
|
||||
'whistleblowing.html': 'whistleblowing.html',
|
||||
'organigramma.html': 'organigramma.html'
|
||||
'organigramma.html': 'organigramma.html',
|
||||
'competenze.html': 'competenze.html'
|
||||
};
|
||||
|
||||
function biHref(href) {
|
||||
@@ -71,6 +72,7 @@
|
||||
label: 'Gestione', i18nKey: 'nav.management',
|
||||
items: [
|
||||
{ name: 'Organigramma', href: 'organigramma.html', icon: '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M8 2.5h4a.5.5 0 01.5.5v2a.5.5 0 01-.5.5h-1.25v2.5H15a.5.5 0 01.5.5v1.25H17a.5.5 0 01.5.5v3a.5.5 0 01-.5.5h-4a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5h1.25V9.5H6v1.25H7.5a.5.5 0 01.5.5v3a.5.5 0 01-.5.5h-4a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5H4.5V9.5a.5.5 0 01.5-.5h4.75V6H8a.5.5 0 01-.5-.5V3a.5.5 0 01.5-.5z"/></svg>', i18nKey: 'nav.org_chart' },
|
||||
{ name: 'Competenze', href: 'competenze.html', icon: '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 11-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-1zM12 2a1 1 0 01.967.744L14.146 7.2 17.5 8.134a1 1 0 010 1.732l-3.354.934-1.18 4.455a1 1 0 01-1.933 0L9.854 10.8 6.5 9.866a1 1 0 010-1.732l3.354-.934 1.179-4.456A1 1 0 0112 2z"/></svg>', i18nKey: 'nav.competences' },
|
||||
{ name: 'Rischi', href: 'risks.html', icon: iconShieldExclamation(), i18nKey: 'nav.risks' },
|
||||
{ name: 'Incidenti', href: 'incidents.html', icon: iconBell(), i18nKey: 'nav.incidents' },
|
||||
{ name: 'Policy', href: 'policies.html', icon: iconDocumentText(), i18nKey: 'nav.policies' },
|
||||
|
||||
@@ -196,6 +196,7 @@ function loadSidebar() {
|
||||
label: 'Gestione', i18nKey: 'nav.management',
|
||||
items: [
|
||||
{ name: 'Organigramma', href: 'organigramma.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M8 2.5h4a.5.5 0 01.5.5v2a.5.5 0 01-.5.5h-1.25v2.5H15a.5.5 0 01.5.5v1.25H17a.5.5 0 01.5.5v3a.5.5 0 01-.5.5h-4a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5h1.25V9.5H6v1.25H7.5a.5.5 0 01.5.5v3a.5.5 0 01-.5.5h-4a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5H4.5V9.5a.5.5 0 01.5-.5h4.75V6H8a.5.5 0 01-.5-.5V3a.5.5 0 01.5-.5z"/></svg>`, i18nKey: 'nav.org_chart' },
|
||||
{ name: 'Competenze', href: 'competenze.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 11-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-1zM12 2a1 1 0 01.967.744L14.146 7.2 17.5 8.134a1 1 0 010 1.732l-3.354.934-1.18 4.455a1 1 0 01-1.933 0L9.854 10.8 6.5 9.866a1 1 0 010-1.732l3.354-.934 1.179-4.456A1 1 0 0112 2z"/></svg>`, i18nKey: 'nav.competences' },
|
||||
{ name: 'Rischi', href: 'risks.html', icon: iconShieldExclamation(), i18nKey: 'nav.risks' },
|
||||
{ name: 'Incidenti', href: 'incidents.html', icon: iconBell(), i18nKey: 'nav.incidents' },
|
||||
{ name: 'Policy', href: 'policies.html', icon: iconDocumentText(), i18nKey: 'nav.policies' },
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* NIS2 Agile - Competenze (A4 Fase 4.2)
|
||||
* 4 tab: Catalogo competenze · Requisiti per ruolo · Competenze persone · Gap & azioni.
|
||||
* Client api.comp*: ritorna `data`, lancia su success=false (api.js _acn).
|
||||
* Alert->azione: openAction crea NCR+CAPA (workflow esistente). Ancoraggio PR.AT-01/02, GV.RR-04.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
let CMP = { skills: [], roles: [], members: [], courses: [], tab: 'catalog' };
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
||||
|
||||
const LEVEL_LABELS = { 1: 'Base', 2: 'Elementare', 3: 'Intermedio', 4: 'Avanzato', 5: 'Esperto' };
|
||||
function levelLabel(n) { return LEVEL_LABELS[n] || ('Liv. ' + n); }
|
||||
function levelOptions(sel) {
|
||||
let h = '';
|
||||
for (let i = 1; i <= 5; i++) h += '<option value="' + i + '"' + (Number(sel) === i ? ' selected' : '') + '>' + i + ' · ' + LEVEL_LABELS[i] + '</option>';
|
||||
return h;
|
||||
}
|
||||
|
||||
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') { cmpClose('cmp-skill-modal'); cmpClose('cmp-courses-modal'); }
|
||||
});
|
||||
['cmp-skill-modal', 'cmp-courses-modal'].forEach(function (id) {
|
||||
const ov = el(id);
|
||||
if (ov) ov.addEventListener('click', function (e) { if (e.target === ov) cmpClose(id); });
|
||||
});
|
||||
|
||||
await cmpLoadShared();
|
||||
cmpRenderCatalog();
|
||||
});
|
||||
|
||||
async function cmpLoadShared() {
|
||||
try {
|
||||
const [cat, rolesData, members, coursesRaw] = await Promise.all([
|
||||
api.compCatalog(),
|
||||
api.orgRolesList(),
|
||||
api.orgRolesAssignableUsers(),
|
||||
api.listCourses()
|
||||
]);
|
||||
CMP.skills = cat.skills || [];
|
||||
CMP.roles = (rolesData && rolesData.roles) || [];
|
||||
CMP.members = Array.isArray(members) ? members : [];
|
||||
CMP.courses = (coursesRaw && coursesRaw.success && Array.isArray(coursesRaw.data)) ? coursesRaw.data : [];
|
||||
cmpFillRoleSelect();
|
||||
cmpFillUserSelect();
|
||||
} catch (e) {
|
||||
showNotification('Errore nel caricamento: ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function cmpTab(name) {
|
||||
CMP.tab = name;
|
||||
document.querySelectorAll('.cmp-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||||
document.querySelectorAll('.cmp-panel').forEach(p => p.classList.remove('active'));
|
||||
const panel = el('panel-' + name);
|
||||
if (panel) panel.classList.add('active');
|
||||
if (name === 'gap') cmpLoadGap();
|
||||
}
|
||||
|
||||
/* ════════════════ TAB 1 — Catalogo ════════════════ */
|
||||
function cmpRenderCatalog() {
|
||||
el('cmp-catalog-count').textContent = CMP.skills.length + ' competenze (incluse quelle globali)';
|
||||
if (!CMP.skills.length) {
|
||||
el('cmp-catalog').innerHTML = '<div class="cmp-empty">Nessuna competenza nel catalogo. Creane una con "+ Nuova competenza".</div>';
|
||||
return;
|
||||
}
|
||||
let rows = CMP.skills.map(function (s) {
|
||||
const courses = (s.courses || []);
|
||||
const actions = s.editable
|
||||
? '<button class="btn btn-outline" onclick="cmpOpenSkill(' + s.id + ')">Modifica</button>'
|
||||
+ '<button class="btn btn-outline" onclick="cmpOpenCourses(' + s.id + ')">Corsi</button>'
|
||||
+ '<button class="btn btn-outline" onclick="cmpDeleteSkill(' + s.id + ')">Elimina</button>'
|
||||
: '<span class="gap-role-sub">sola lettura</span>';
|
||||
return '<tr>'
|
||||
+ '<td><strong>' + esc(s.name) + '</strong>' + (s.is_global ? ' <span class="cmp-badge global">globale</span>' : '')
|
||||
+ (s.description ? '<div class="gap-role-sub">' + esc(s.description) + '</div>' : '') + '</td>'
|
||||
+ '<td>' + (s.area ? '<span class="cmp-badge area">' + esc(s.area) + '</span>' : '—') + '</td>'
|
||||
+ '<td>' + (courses.length ? courses.map(c => esc(c.title)).join(', ') : '—') + '</td>'
|
||||
+ '<td><div class="cmp-actions-cell">' + actions + '</div></td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
el('cmp-catalog').innerHTML = '<table class="cmp-table"><thead><tr><th>Competenza</th><th>Area</th><th>Corsi che la erogano</th><th>Azioni</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
function cmpOpenSkill(id) {
|
||||
const s = id ? CMP.skills.find(x => x.id === id) : null;
|
||||
el('cmp-skill-title').textContent = s ? 'Modifica competenza' : 'Nuova competenza';
|
||||
el('cmp-skill-id').value = s ? s.id : '';
|
||||
el('cmp-skill-name').value = s ? s.name : '';
|
||||
el('cmp-skill-area').value = s ? (s.area || '') : '';
|
||||
el('cmp-skill-desc').value = s ? (s.description || '') : '';
|
||||
el('cmp-skill-err').textContent = '';
|
||||
el('cmp-skill-modal').classList.add('open');
|
||||
setTimeout(() => el('cmp-skill-name').focus(), 50);
|
||||
}
|
||||
|
||||
async function cmpSaveSkill() {
|
||||
const id = el('cmp-skill-id').value;
|
||||
const name = el('cmp-skill-name').value.trim();
|
||||
if (!name) { el('cmp-skill-err').textContent = 'Il nome è obbligatorio.'; return; }
|
||||
const payload = { name: name, area: el('cmp-skill-area').value.trim(), description: el('cmp-skill-desc').value.trim() };
|
||||
const btn = el('cmp-skill-save'); btn.disabled = true;
|
||||
try {
|
||||
if (id) { await api.compUpdateSkill(parseInt(id, 10), payload); showNotification('Competenza aggiornata.', 'success'); }
|
||||
else { await api.compCreateSkill(payload); showNotification('Competenza creata.', 'success'); }
|
||||
cmpClose('cmp-skill-modal');
|
||||
await cmpReloadCatalog();
|
||||
} catch (e) {
|
||||
el('cmp-skill-err').textContent = e.message || 'Errore nel salvataggio.';
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
async function cmpDeleteSkill(id) {
|
||||
const s = CMP.skills.find(x => x.id === id);
|
||||
if (!confirm('Eliminare la competenza "' + (s ? s.name : '') + '"? Verranno rimossi anche i requisiti e le competenze possedute collegate.')) return;
|
||||
try {
|
||||
await api.compDeleteSkill(id);
|
||||
showNotification('Competenza eliminata.', 'success');
|
||||
await cmpReloadCatalog();
|
||||
} catch (e) { showNotification(e.message || 'Errore.', 'error'); }
|
||||
}
|
||||
|
||||
async function cmpReloadCatalog() {
|
||||
const cat = await api.compCatalog();
|
||||
CMP.skills = cat.skills || [];
|
||||
cmpRenderCatalog();
|
||||
}
|
||||
|
||||
/* Mappatura corsi */
|
||||
function cmpOpenCourses(skillId) {
|
||||
el('cmp-courses-skill-id').value = skillId;
|
||||
el('cmp-courses-err').textContent = '';
|
||||
const s = CMP.skills.find(x => x.id === skillId);
|
||||
el('cmp-courses-title').textContent = 'Corsi per: ' + (s ? s.name : '');
|
||||
cmpRenderCoursesModal();
|
||||
el('cmp-courses-modal').classList.add('open');
|
||||
}
|
||||
|
||||
function cmpRenderCoursesModal() {
|
||||
const skillId = parseInt(el('cmp-courses-skill-id').value, 10);
|
||||
const s = CMP.skills.find(x => x.id === skillId);
|
||||
const mapped = s ? (s.courses || []) : [];
|
||||
const mappedIds = mapped.map(c => c.course_id);
|
||||
// select: corsi non ancora mappati
|
||||
const avail = CMP.courses.filter(c => mappedIds.indexOf(c.id) === -1);
|
||||
el('cmp-courses-sel').innerHTML = avail.length
|
||||
? avail.map(c => '<option value="' + c.id + '">' + esc(c.title) + '</option>').join('')
|
||||
: '<option value="">— Nessun corso disponibile —</option>';
|
||||
el('cmp-courses-list').innerHTML = mapped.length
|
||||
? mapped.map(c => '<div class="cm-maprow"><span>' + esc(c.title) + '</span><button class="btn btn-outline btn-sm" onclick="cmpUnmapCourse(' + c.map_id + ')">Rimuovi</button></div>').join('')
|
||||
: '<div class="gap-role-sub" style="padding-top:8px;">Nessun corso mappato.</div>';
|
||||
}
|
||||
|
||||
async function cmpMapCourse() {
|
||||
const skillId = parseInt(el('cmp-courses-skill-id').value, 10);
|
||||
const courseId = parseInt(el('cmp-courses-sel').value, 10);
|
||||
if (!courseId) { el('cmp-courses-err').textContent = 'Seleziona un corso.'; return; }
|
||||
try {
|
||||
await api.compMapCourse({ skill_id: skillId, training_course_id: courseId });
|
||||
await cmpReloadCatalog();
|
||||
cmpRenderCoursesModal();
|
||||
showNotification('Corso mappato.', 'success');
|
||||
} catch (e) { el('cmp-courses-err').textContent = e.message || 'Errore.'; }
|
||||
}
|
||||
|
||||
async function cmpUnmapCourse(mapId) {
|
||||
try {
|
||||
await api.compUnmapCourse(mapId);
|
||||
await cmpReloadCatalog();
|
||||
cmpRenderCoursesModal();
|
||||
showNotification('Mappatura rimossa.', 'success');
|
||||
} catch (e) { el('cmp-courses-err').textContent = e.message || 'Errore.'; }
|
||||
}
|
||||
|
||||
/* ════════════════ TAB 2 — Requisiti per ruolo ════════════════ */
|
||||
function cmpFillRoleSelect() {
|
||||
const sel = el('cmp-role-sel');
|
||||
if (!CMP.roles.length) {
|
||||
sel.innerHTML = '<option value="">— Nessun ruolo: creane nell\'Organigramma —</option>';
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = '<option value="">— Seleziona un ruolo —</option>' +
|
||||
CMP.roles.map(r => '<option value="' + r.id + '">' + esc(r.role_name) + (r.holder_name ? ' (' + esc(r.holder_name) + ')' : '') + '</option>').join('');
|
||||
}
|
||||
|
||||
async function cmpLoadRoleSkills() {
|
||||
const roleId = parseInt(el('cmp-role-sel').value, 10);
|
||||
const wrap = el('cmp-role-skills');
|
||||
if (!roleId) { el('cmp-role-add').style.display = 'none'; wrap.innerHTML = ''; return; }
|
||||
el('cmp-role-add').style.display = 'flex';
|
||||
try {
|
||||
const rs = await api.compRoleSkills(roleId);
|
||||
// skill select: escludi quelle gia richieste
|
||||
const usedIds = rs.map(x => x.skill_id);
|
||||
el('cmp-rs-skill').innerHTML = CMP.skills.filter(s => usedIds.indexOf(s.id) === -1)
|
||||
.map(s => '<option value="' + s.id + '">' + esc(s.name) + (s.area ? ' [' + esc(s.area) + ']' : '') + '</option>').join('') || '<option value="">— Tutte già assegnate —</option>';
|
||||
el('cmp-rs-level').innerHTML = levelOptions(3);
|
||||
wrap.innerHTML = rs.length ? cmpRoleSkillsTable(rs) : '<div class="cmp-empty">Nessun requisito per questo ruolo. Aggiungine uno.</div>';
|
||||
} catch (e) { showNotification(e.message || 'Errore.', 'error'); }
|
||||
}
|
||||
|
||||
function cmpRoleSkillsTable(rs) {
|
||||
const rows = rs.map(x => '<tr>'
|
||||
+ '<td><strong>' + esc(x.skill_name) + '</strong>' + (x.area ? ' <span class="cmp-badge area">' + esc(x.area) + '</span>' : '') + '</td>'
|
||||
+ '<td><span class="cmp-badge lvl">Liv. ' + x.required_level + ' · ' + levelLabel(x.required_level) + '</span></td>'
|
||||
+ '<td><button class="btn btn-outline btn-sm" onclick="cmpRemoveRoleSkill(' + x.id + ')">Rimuovi</button></td>'
|
||||
+ '</tr>').join('');
|
||||
return '<table class="cmp-table"><thead><tr><th>Competenza richiesta</th><th>Livello</th><th></th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
async function cmpAddRoleSkill() {
|
||||
const roleId = parseInt(el('cmp-role-sel').value, 10);
|
||||
const skillId = parseInt(el('cmp-rs-skill').value, 10);
|
||||
const level = parseInt(el('cmp-rs-level').value, 10);
|
||||
if (!roleId || !skillId) { showNotification('Seleziona ruolo e competenza.', 'error'); return; }
|
||||
try {
|
||||
await api.compSetRoleSkill({ role_id: roleId, skill_id: skillId, required_level: level });
|
||||
showNotification('Requisito aggiunto.', 'success');
|
||||
await cmpLoadRoleSkills();
|
||||
} catch (e) { showNotification(e.message || 'Errore.', 'error'); }
|
||||
}
|
||||
|
||||
async function cmpRemoveRoleSkill(id) {
|
||||
if (!confirm('Rimuovere questo requisito?')) return;
|
||||
try { await api.compRemoveRoleSkill(id); showNotification('Requisito rimosso.', 'success'); await cmpLoadRoleSkills(); }
|
||||
catch (e) { showNotification(e.message || 'Errore.', 'error'); }
|
||||
}
|
||||
|
||||
/* ════════════════ TAB 3 — Competenze persone ════════════════ */
|
||||
function cmpFillUserSelect() {
|
||||
const sel = el('cmp-user-sel');
|
||||
sel.innerHTML = '<option value="">— Seleziona una persona —</option>' +
|
||||
CMP.members.map(m => '<option value="' + m.id + '">' + esc(m.full_name || m.email) + '</option>').join('');
|
||||
}
|
||||
|
||||
async function cmpLoadUserSkills() {
|
||||
const userId = parseInt(el('cmp-user-sel').value, 10);
|
||||
const wrap = el('cmp-user-skills');
|
||||
if (!userId) { el('cmp-user-add').style.display = 'none'; wrap.innerHTML = ''; return; }
|
||||
el('cmp-user-add').style.display = 'flex';
|
||||
try {
|
||||
const us = await api.compUserSkills(userId);
|
||||
const usedIds = us.map(x => x.skill_id);
|
||||
el('cmp-us-skill').innerHTML = CMP.skills.filter(s => usedIds.indexOf(s.id) === -1)
|
||||
.map(s => '<option value="' + s.id + '">' + esc(s.name) + (s.area ? ' [' + esc(s.area) + ']' : '') + '</option>').join('') || '<option value="">— Tutte già registrate —</option>';
|
||||
el('cmp-us-level').innerHTML = levelOptions(3);
|
||||
el('cmp-us-course').innerHTML = '<option value="">— Nessuno —</option>' + CMP.courses.map(c => '<option value="' + c.id + '">' + esc(c.title) + '</option>').join('');
|
||||
el('cmp-us-evidence').value = '';
|
||||
wrap.innerHTML = us.length ? cmpUserSkillsTable(us) : '<div class="cmp-empty">Nessuna competenza registrata per questa persona.</div>';
|
||||
} catch (e) { showNotification(e.message || 'Errore.', 'error'); }
|
||||
}
|
||||
|
||||
function cmpUserSkillsTable(us) {
|
||||
const rows = us.map(x => '<tr>'
|
||||
+ '<td><strong>' + esc(x.skill_name) + '</strong>' + (x.area ? ' <span class="cmp-badge area">' + esc(x.area) + '</span>' : '') + '</td>'
|
||||
+ '<td><span class="cmp-badge lvl">Liv. ' + x.level + ' · ' + levelLabel(x.level) + '</span></td>'
|
||||
+ '<td>' + (x.course_title ? esc(x.course_title) : (x.evidence ? esc(x.evidence) : '—')) + '</td>'
|
||||
+ '<td><button class="btn btn-outline btn-sm" onclick="cmpRemoveUserSkill(' + x.id + ')">Rimuovi</button></td>'
|
||||
+ '</tr>').join('');
|
||||
return '<table class="cmp-table"><thead><tr><th>Competenza</th><th>Livello</th><th>Evidenza / Corso</th><th></th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
async function cmpAddUserSkill() {
|
||||
const userId = parseInt(el('cmp-user-sel').value, 10);
|
||||
const skillId = parseInt(el('cmp-us-skill').value, 10);
|
||||
const level = parseInt(el('cmp-us-level').value, 10);
|
||||
const courseId = el('cmp-us-course').value || null;
|
||||
const evidence = el('cmp-us-evidence').value.trim();
|
||||
if (!userId || !skillId) { showNotification('Seleziona persona e competenza.', 'error'); return; }
|
||||
try {
|
||||
await api.compSetUserSkill({ user_id: userId, skill_id: skillId, level: level, acquired_via_course_id: courseId, evidence: evidence });
|
||||
showNotification('Competenza registrata.', 'success');
|
||||
await cmpLoadUserSkills();
|
||||
} catch (e) { showNotification(e.message || 'Errore.', 'error'); }
|
||||
}
|
||||
|
||||
async function cmpRemoveUserSkill(id) {
|
||||
if (!confirm('Rimuovere questa competenza?')) return;
|
||||
try { await api.compRemoveUserSkill(id); showNotification('Competenza rimossa.', 'success'); await cmpLoadUserSkills(); }
|
||||
catch (e) { showNotification(e.message || 'Errore.', 'error'); }
|
||||
}
|
||||
|
||||
/* ════════════════ TAB 4 — Gap & azioni ════════════════ */
|
||||
async function cmpLoadGap() {
|
||||
const wrap = el('cmp-gap');
|
||||
wrap.innerHTML = '<div class="cmp-empty">Calcolo in corso…</div>';
|
||||
try {
|
||||
const g = await api.compGapGrid();
|
||||
const note = g.pr_at_02_applies
|
||||
? 'Soggetto essenziale: si applica anche PR.AT-02 (formazione mirata).'
|
||||
: 'Soggetto ' + (g.entity_type === 'important' ? 'importante' : 'non in ambito') + ': si applica PR.AT-01.';
|
||||
el('cmp-gap-summary').textContent = g.roles_with_holder + ' ruoli con titolare · ' + g.roles_with_gaps + ' con gap · ' + g.total_gaps + ' gap totali · ' + note;
|
||||
if (!g.roles || !g.roles.length) {
|
||||
wrap.innerHTML = '<div class="cmp-empty">Nessun ruolo con titolare e requisiti di competenza. Assegna titolari nell\'Organigramma e requisiti nella scheda "Requisiti per ruolo".</div>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML = g.roles.map(cmpGapRoleCard).join('');
|
||||
} catch (e) {
|
||||
wrap.innerHTML = '<div class="cmp-empty">Errore: ' + esc(e.message || e) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function cmpGapRoleCard(role) {
|
||||
const rows = (role.skills || []).map(function (sk) {
|
||||
const pill = sk.gap > 0
|
||||
? '<span class="gap-pill bad">gap ' + sk.gap + '</span>'
|
||||
: '<span class="gap-pill ok">ok</span>';
|
||||
let ctas = '';
|
||||
if (sk.gap > 0) {
|
||||
if (sk.open_action) {
|
||||
ctas = '<span class="cmp-badge area" title="Non conformità aperta">NC ' + esc(sk.open_action.ncr_code) + '</span>';
|
||||
} else {
|
||||
ctas = '<button class="btn btn-primary" onclick="cmpOpenAction(' + sk.role_skill_id + ')">Apri non conformità</button>';
|
||||
(sk.suggested_courses || []).slice(0, 3).forEach(function (c) {
|
||||
ctas += '<button class="btn btn-outline" onclick="cmpAssignCourse(' + c.id + ',' + role.holder_user_id + ')">Assegna: ' + esc(c.title) + '</button>';
|
||||
});
|
||||
}
|
||||
}
|
||||
return '<div class="gap-row">'
|
||||
+ '<div class="skill">' + esc(sk.skill_name) + (sk.area ? ' <span class="cmp-badge area">' + esc(sk.area) + '</span>' : '') + '</div>'
|
||||
+ '<div class="gap-meter">richiesto <strong>' + sk.required_level + '</strong> · posseduto <strong>' + sk.current_level + '</strong></div>'
|
||||
+ '<div>' + pill + '</div>'
|
||||
+ '<div class="ctas">' + ctas + '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
return '<div class="gap-role">'
|
||||
+ '<div class="gap-role-head"><div><span class="gap-role-title">' + esc(role.role_name) + '</span> '
|
||||
+ '<span class="gap-role-sub">— ' + esc(role.holder_name) + '</span></div>'
|
||||
+ '<div>' + (role.gap_count > 0 ? '<span class="gap-pill bad">' + role.gap_count + ' gap</span>' : '<span class="gap-pill ok">nessun gap</span>') + '</div></div>'
|
||||
+ rows + '</div>';
|
||||
}
|
||||
|
||||
async function cmpOpenAction(roleSkillId) {
|
||||
if (!confirm('Aprire una non conformità (NCR) e un\'azione correttiva per questo gap?')) return;
|
||||
try {
|
||||
const r = await api.compOpenAction(roleSkillId);
|
||||
showNotification('Creata non conformità ' + r.ncr_code + ' con azione ' + r.capa_code + '.', 'success');
|
||||
await cmpLoadGap();
|
||||
} catch (e) {
|
||||
// 409 = azione già aperta
|
||||
showNotification(e.message || 'Errore.', e.error_code === 'ACTION_EXISTS' ? 'info' : 'error');
|
||||
if (e.error_code === 'ACTION_EXISTS') await cmpLoadGap();
|
||||
}
|
||||
}
|
||||
|
||||
async function cmpAssignCourse(courseId, userId) {
|
||||
if (!userId) { showNotification('Il ruolo non ha un titolare a cui assegnare il corso.', 'error'); return; }
|
||||
const c = CMP.courses.find(x => x.id === courseId);
|
||||
const title = c ? c.title : 'corso';
|
||||
if (!confirm('Assegnare il corso "' + title + '" al titolare del ruolo?')) return;
|
||||
try {
|
||||
const r = await api.assignTraining(courseId, [userId]);
|
||||
if (r && r.success) showNotification('Corso assegnato.', 'success');
|
||||
else showNotification((r && r.message) || 'Errore nell\'assegnazione.', 'error');
|
||||
} catch (e) { showNotification(e.message || 'Errore.', 'error'); }
|
||||
}
|
||||
|
||||
/* ════════════════ util ════════════════ */
|
||||
function cmpClose(id) { const m = el(id); if (m) m.classList.remove('open'); }
|
||||
@@ -238,6 +238,44 @@ const HelpSystem = (function () {
|
||||
]
|
||||
},
|
||||
|
||||
// ─── Competenze (A4 Fase 4.2) ────────────────────────────────
|
||||
'competences': {
|
||||
title: 'Guida - Competenze',
|
||||
intro: 'Il modulo Competenze collega le persone ai ruoli dell\'organigramma attraverso le competenze: quelle richieste da ciascun ruolo e quelle effettivamente possedute. Confrontandole, individua i divari (gap) e ti aiuta a colmarli con la formazione o aprendo un\'azione correttiva. E\' uno strumento di supporto, non un parere legale.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Le quattro schede',
|
||||
items: [
|
||||
'<strong>Catalogo competenze</strong>: l\'elenco delle competenze (con area e descrizione). Puoi mappare a ciascuna i corsi che la erogano. Le competenze "globali" sono fornite dal sistema e sono in sola lettura.',
|
||||
'<strong>Requisiti per ruolo</strong>: per ogni ruolo dell\'organigramma indichi quali competenze servono e a quale livello (scala 1-5).',
|
||||
'<strong>Competenze persone</strong>: registri le competenze possedute da ciascun membro, con il livello, l\'eventuale corso che le ha fornite e un\'evidenza.',
|
||||
'<strong>Gap & azioni</strong>: confronta, per ogni ruolo con titolare, il livello richiesto con quello posseduto e mostra i gap.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Come si calcola il gap e cosa farne',
|
||||
items: [
|
||||
'Il gap di una competenza = livello richiesto dal ruolo − livello posseduto dal titolare. Se è maggiore di zero, c\'è un divario da colmare.',
|
||||
'<strong>Assegna corso</strong>: assegna al titolare uno dei corsi mappati alla competenza (gestito nel modulo Formazione).',
|
||||
'<strong>Apri non conformità</strong>: genera una Non Conformità (NCR) con un\'azione correttiva collegata, riusando il modulo NCR/CAPA. L\'azione resta tracciata fino alla chiusura; il sistema evita doppioni sullo stesso gap.'
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Riferimenti normativi',
|
||||
items: [
|
||||
'<strong>PR.AT-01</strong>: formazione e consapevolezza per tutto il personale (vale per tutti i soggetti).',
|
||||
'<strong>PR.AT-02</strong>: formazione mirata per i ruoli a maggior rischio (richiesto in particolare ai soggetti <em>essenziali</em>).',
|
||||
'<strong>GV.RR-04</strong>: la cybersicurezza è inclusa nelle pratiche di gestione del personale (HR).'
|
||||
]
|
||||
}
|
||||
],
|
||||
references: [
|
||||
'D.Lgs. 138/2024, art. 24 - Obblighi in materia di misure di gestione del rischio (include la formazione)',
|
||||
'Misure di base ACN (Determinazione n. 164179/2025), funzione Protect: PR.AT-01 (tutti), PR.AT-02 (essenziali); GV.RR-04',
|
||||
'NOTA: gli obblighi normativi in Italia derivano da NIS2 (Dir. UE 2022/2555) e dal D.Lgs. 138/2024; i livelli di competenza e i framework sono strumenti di supporto, non un parere legale.'
|
||||
]
|
||||
},
|
||||
|
||||
// ─── Risk Management ─────────────────────────────────────────
|
||||
'risks': {
|
||||
title: 'Guida - Gestione Rischi',
|
||||
@@ -1078,6 +1116,8 @@ const HelpSystem = (function () {
|
||||
'isms': 'isms',
|
||||
'organigramma.html': 'org',
|
||||
'organigramma': 'org',
|
||||
'competenze.html': 'competences',
|
||||
'competenze': 'competences',
|
||||
'risks.html': 'risks',
|
||||
'risks': 'risks',
|
||||
'incidents.html': 'incidents',
|
||||
|
||||
@@ -79,9 +79,11 @@ const I18n = (function () {
|
||||
'isms.s6.readiness': { it: 'Completamento del SGSI', en: 'ISMS completion' },
|
||||
'nav.management': { it: 'Gestione', en: 'Management' },
|
||||
'nav.org_chart': { it: 'Organigramma', en: 'Org Chart' },
|
||||
'nav.competences': { it: 'Competenze', en: 'Skills' },
|
||||
'org.title': { it: 'Organigramma', en: 'Org Chart' },
|
||||
'org.subtitle': { it: 'Ruoli, responsabilità e organi di governance', en: 'Roles, responsibilities and governance bodies' },
|
||||
'org.new_role': { it: 'Nuovo ruolo', en: 'New role' },
|
||||
'comp.title': { it: 'Competenze', en: 'Skills' },
|
||||
'nav.risks': { it: 'Rischi', en: 'Risks' },
|
||||
'nav.incidents': { it: 'Incidenti', en: 'Incidents' },
|
||||
'nav.policies': { it: 'Policy', en: 'Policies' },
|
||||
|
||||
Reference in New Issue
Block a user