/** * 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 += ''; 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 ? '' + '' + '' : '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 = '' + rows + '
CompetenzaAreaCorsi che la eroganoAzioni
'; } 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 => '').join('') : ''; el('cmp-courses-list').innerHTML = mapped.length ? mapped.map(c => '
' + esc(c.title) + '
').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 = ''; return; } sel.innerHTML = '' + CMP.roles.map(r => '').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 => '').join('') || ''; 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) + '' + '' + '').join(''); return '' + rows + '
Competenza richiestaLivello
'; } 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 = '' + CMP.members.map(m => '').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 => '').join('') || ''; el('cmp-us-level').innerHTML = levelOptions(3); el('cmp-us-course').innerHTML = '' + CMP.courses.map(c => '').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) : '—')) + '' + '' + '').join(''); return '' + rows + '
CompetenzaLivelloEvidenza / Corso
'; } 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 = ''; (sk.suggested_courses || []).slice(0, 3).forEach(function (c) { ctas += ''; }); } } 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'); }