/**
* 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 += '' + i + ' · ' + LEVEL_LABELS[i] + ' ';
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 = '
Nessuna competenza nel catalogo. Creane una con "+ Nuova competenza".
';
return;
}
let rows = CMP.skills.map(function (s) {
const courses = (s.courses || []);
const actions = s.editable
? 'Modifica '
+ 'Corsi '
+ 'Elimina '
: 'sola lettura ';
return ''
+ '' + esc(s.name) + ' ' + (s.is_global ? ' globale ' : '')
+ (s.description ? '' + esc(s.description) + '
' : '') + ' '
+ '' + (s.area ? '' + esc(s.area) + ' ' : '—') + ' '
+ '' + (courses.length ? courses.map(c => esc(c.title)).join(', ') : '—') + ' '
+ '' + actions + '
'
+ ' ';
}).join('');
el('cmp-catalog').innerHTML = 'Competenza Area Corsi che la erogano Azioni ' + rows + '
';
}
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 => '' + esc(c.title) + ' ').join('')
: '— Nessun corso disponibile — ';
el('cmp-courses-list').innerHTML = mapped.length
? mapped.map(c => '' + esc(c.title) + ' Rimuovi
').join('')
: 'Nessun corso mappato.
';
}
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 = '— Nessun ruolo: creane nell\'Organigramma — ';
return;
}
sel.innerHTML = '— Seleziona un ruolo — ' +
CMP.roles.map(r => '' + esc(r.role_name) + (r.holder_name ? ' (' + esc(r.holder_name) + ')' : '') + ' ').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 => '' + esc(s.name) + (s.area ? ' [' + esc(s.area) + ']' : '') + ' ').join('') || '— Tutte già assegnate — ';
el('cmp-rs-level').innerHTML = levelOptions(3);
wrap.innerHTML = rs.length ? cmpRoleSkillsTable(rs) : 'Nessun requisito per questo ruolo. Aggiungine uno.
';
} catch (e) { showNotification(e.message || 'Errore.', 'error'); }
}
function cmpRoleSkillsTable(rs) {
const rows = rs.map(x => ''
+ '' + esc(x.skill_name) + ' ' + (x.area ? ' ' + esc(x.area) + ' ' : '') + ' '
+ 'Liv. ' + x.required_level + ' · ' + levelLabel(x.required_level) + ' '
+ 'Rimuovi '
+ ' ').join('');
return 'Competenza richiesta Livello ' + rows + '
';
}
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 = '— Seleziona una persona — ' +
CMP.members.map(m => '' + esc(m.full_name || m.email) + ' ').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 => '' + esc(s.name) + (s.area ? ' [' + esc(s.area) + ']' : '') + ' ').join('') || '— Tutte già registrate — ';
el('cmp-us-level').innerHTML = levelOptions(3);
el('cmp-us-course').innerHTML = '— Nessuno — ' + CMP.courses.map(c => '' + esc(c.title) + ' ').join('');
el('cmp-us-evidence').value = '';
wrap.innerHTML = us.length ? cmpUserSkillsTable(us) : 'Nessuna competenza registrata per questa persona.
';
} catch (e) { showNotification(e.message || 'Errore.', 'error'); }
}
function cmpUserSkillsTable(us) {
const rows = us.map(x => ''
+ '' + esc(x.skill_name) + ' ' + (x.area ? ' ' + esc(x.area) + ' ' : '') + ' '
+ 'Liv. ' + x.level + ' · ' + levelLabel(x.level) + ' '
+ '' + (x.course_title ? esc(x.course_title) : (x.evidence ? esc(x.evidence) : '—')) + ' '
+ 'Rimuovi '
+ ' ').join('');
return 'Competenza Livello Evidenza / Corso ' + rows + '
';
}
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 = 'Calcolo in corso…
';
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 = 'Nessun ruolo con titolare e requisiti di competenza. Assegna titolari nell\'Organigramma e requisiti nella scheda "Requisiti per ruolo".
';
return;
}
wrap.innerHTML = g.roles.map(cmpGapRoleCard).join('');
} catch (e) {
wrap.innerHTML = 'Errore: ' + esc(e.message || e) + '
';
}
}
function cmpGapRoleCard(role) {
const rows = (role.skills || []).map(function (sk) {
const pill = sk.gap > 0
? 'gap ' + sk.gap + ' '
: 'ok ';
let ctas = '';
if (sk.gap > 0) {
if (sk.open_action) {
ctas = 'NC ' + esc(sk.open_action.ncr_code) + ' ';
} else {
ctas = 'Apri non conformità ';
(sk.suggested_courses || []).slice(0, 3).forEach(function (c) {
ctas += 'Assegna: ' + esc(c.title) + ' ';
});
}
}
return ''
+ '
' + esc(sk.skill_name) + (sk.area ? ' ' + esc(sk.area) + ' ' : '') + '
'
+ '
richiesto ' + sk.required_level + ' · posseduto ' + sk.current_level + '
'
+ '
' + pill + '
'
+ '
' + ctas + '
'
+ '
';
}).join('');
return ''
+ '
' + esc(role.role_name) + ' '
+ '— ' + esc(role.holder_name) + '
'
+ '
' + (role.gap_count > 0 ? '' + role.gap_count + ' gap ' : 'nessun gap ') + '
'
+ rows + '
';
}
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'); }