[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
@@ -0,0 +1,700 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Competenze (A4 Fase 4.2)
|
||||
* ----------------------------------------------------------------------------
|
||||
* Catalogo competenze (skills), requisiti per ruolo (role_skills), competenze
|
||||
* possedute dagli utenti (user_skills), mappatura competenza<->corso
|
||||
* (skill_course_map), calcolo del GAP competenze e apertura di un'azione
|
||||
* correttiva dal gap riusando il workflow NCR/CAPA esistente.
|
||||
*
|
||||
* Multi-tenancy ancorata a getCurrentOrgId(); scritture riservate a
|
||||
* org_admin/compliance_manager (super_admin bypassa). Anti-IDOR su (id +
|
||||
* organization_id). Le competenze globali (skills.organization_id NULL) sono in
|
||||
* sola lettura per gli utenti dell'org (solo super_admin le gestisce).
|
||||
*
|
||||
* Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md sez.3/4): PR.AT-01 (tutti),
|
||||
* PR.AT-02 (solo soggetti essenziali), GV.RR-04 (cyber nelle pratiche HR).
|
||||
*
|
||||
* NB: la tabella delle azioni e 'capa_actions' (figlia di 'non_conformities'),
|
||||
* NON 'corrective_actions' (che non esiste). openAction() crea una NCR ancorata
|
||||
* al gap (source_entity_type='competence_gap') + una CAPA collegata.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
|
||||
class CompetenceController extends BaseController
|
||||
{
|
||||
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// CATALOGO COMPETENZE (skills)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /api/competences/catalog — skill visibili (org + globali) + corsi mappati. */
|
||||
public function catalog(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$skills = Database::fetchAll(
|
||||
'SELECT s.id, s.organization_id, s.name, s.area, s.description, s.created_at, s.updated_at
|
||||
FROM skills s
|
||||
WHERE s.organization_id = ? OR s.organization_id IS NULL
|
||||
ORDER BY (s.organization_id IS NULL) DESC, s.area, s.name',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
// Corsi mappati per ciascuna skill (corsi visibili: org o globali).
|
||||
$maps = Database::fetchAll(
|
||||
'SELECT scm.id AS map_id, scm.skill_id, scm.training_course_id, tc.title
|
||||
FROM skill_course_map scm
|
||||
JOIN training_courses tc ON tc.id = scm.training_course_id
|
||||
WHERE tc.organization_id = ? OR tc.organization_id IS NULL',
|
||||
[$orgId]
|
||||
);
|
||||
$coursesBySkill = [];
|
||||
foreach ($maps as $m) {
|
||||
$coursesBySkill[(int) $m['skill_id']][] = [
|
||||
'map_id' => (int) $m['map_id'],
|
||||
'course_id' => (int) $m['training_course_id'],
|
||||
'title' => $m['title'],
|
||||
];
|
||||
}
|
||||
|
||||
$isSuper = ($this->currentUser['role'] ?? '') === 'super_admin';
|
||||
$out = array_map(function ($s) use ($coursesBySkill, $isSuper, $orgId) {
|
||||
$s = $this->normalizeSkill($s);
|
||||
$s['courses'] = $coursesBySkill[$s['id']] ?? [];
|
||||
$s['editable'] = $s['is_global'] ? $isSuper : ($s['organization_id'] === $orgId);
|
||||
return $s;
|
||||
}, $skills);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'skills' => $out,
|
||||
'total' => count($out),
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/competences/skills — {name, area?, description?, global?(super_admin)} */
|
||||
public function createSkill(): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
|
||||
$name = trim((string) $this->getParam('name', ''));
|
||||
if ($name === '') {
|
||||
$this->jsonError('Il nome della competenza e obbligatorio', 422, 'SKILL_NAME_REQUIRED');
|
||||
}
|
||||
if (mb_strlen($name) > 150) {
|
||||
$this->jsonError('Il nome della competenza supera 150 caratteri', 422, 'SKILL_NAME_TOO_LONG');
|
||||
}
|
||||
|
||||
// Solo super_admin puo creare competenze globali (organization_id NULL).
|
||||
$isSuper = ($this->currentUser['role'] ?? '') === 'super_admin';
|
||||
$orgId = ($isSuper && $this->getParam('global')) ? null : $this->getCurrentOrgId();
|
||||
|
||||
$id = Database::insert('skills', [
|
||||
'organization_id' => $orgId,
|
||||
'name' => $name,
|
||||
'area' => $this->nullableText($this->getParam('area'), 100),
|
||||
'description' => $this->nullableText($this->getParam('description')),
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
|
||||
$this->logAudit('skill_created', 'skill', $id, ['name' => $name, 'global' => $orgId === null]);
|
||||
$skill = Database::fetchOne('SELECT * FROM skills WHERE id = ?', [$id]);
|
||||
$this->jsonSuccess($this->normalizeSkill($skill), 'Competenza creata', 201);
|
||||
}
|
||||
|
||||
/** PUT /api/competences/skills/{id} */
|
||||
public function updateSkill(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$skill = $this->fetchVisibleSkillOrFail($id);
|
||||
$this->assertSkillWritable($skill);
|
||||
|
||||
$updates = [];
|
||||
if ($this->hasParam('name')) {
|
||||
$name = trim((string) $this->getParam('name', ''));
|
||||
if ($name === '') {
|
||||
$this->jsonError('Il nome della competenza e obbligatorio', 422, 'SKILL_NAME_REQUIRED');
|
||||
}
|
||||
if (mb_strlen($name) > 150) {
|
||||
$this->jsonError('Il nome della competenza supera 150 caratteri', 422, 'SKILL_NAME_TOO_LONG');
|
||||
}
|
||||
$updates['name'] = $name;
|
||||
}
|
||||
if ($this->hasParam('area')) {
|
||||
$updates['area'] = $this->nullableText($this->getParam('area'), 100);
|
||||
}
|
||||
if ($this->hasParam('description')) {
|
||||
$updates['description'] = $this->nullableText($this->getParam('description'));
|
||||
}
|
||||
if (empty($updates)) {
|
||||
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
|
||||
}
|
||||
|
||||
Database::update('skills', $updates, 'id = ?', [$id]);
|
||||
$this->logAudit('skill_updated', 'skill', $id, $updates);
|
||||
$skill = Database::fetchOne('SELECT * FROM skills WHERE id = ?', [$id]);
|
||||
$this->jsonSuccess($this->normalizeSkill($skill), 'Competenza aggiornata');
|
||||
}
|
||||
|
||||
/** DELETE /api/competences/skills/{id} — cascata su role_skills/user_skills/skill_course_map. */
|
||||
public function deleteSkill(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$skill = $this->fetchVisibleSkillOrFail($id);
|
||||
$this->assertSkillWritable($skill);
|
||||
|
||||
$usedRole = Database::count('role_skills', 'skill_id = ?', [$id]);
|
||||
$usedUser = Database::count('user_skills', 'skill_id = ?', [$id]);
|
||||
|
||||
Database::delete('skills', 'id = ?', [$id]);
|
||||
$this->logAudit('skill_deleted', 'skill', $id, ['role_links' => $usedRole, 'user_links' => $usedUser]);
|
||||
$this->jsonSuccess([
|
||||
'removed_role_links' => $usedRole,
|
||||
'removed_user_links' => $usedUser,
|
||||
], 'Competenza eliminata');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// REQUISITI PER RUOLO (role_skills)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /api/competences/roleSkills/{roleId} — competenze richieste dal ruolo. */
|
||||
public function roleSkills(int $roleId): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$this->fetchRoleOrFail($roleId);
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT rs.id, rs.role_id, rs.skill_id, rs.required_level, s.name AS skill_name, s.area
|
||||
FROM role_skills rs JOIN skills s ON s.id = rs.skill_id
|
||||
WHERE rs.role_id = ? AND rs.organization_id = ?
|
||||
ORDER BY s.area, s.name',
|
||||
[$roleId, $this->getCurrentOrgId()]
|
||||
);
|
||||
$this->jsonSuccess(array_map(fn($r) => [
|
||||
'id' => (int) $r['id'],
|
||||
'role_id' => (int) $r['role_id'],
|
||||
'skill_id' => (int) $r['skill_id'],
|
||||
'skill_name' => $r['skill_name'],
|
||||
'area' => $r['area'],
|
||||
'required_level' => (int) $r['required_level'],
|
||||
], $rows));
|
||||
}
|
||||
|
||||
/** POST /api/competences/roleSkills — {role_id, skill_id, required_level} upsert. */
|
||||
public function setRoleSkill(): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$this->validateRequired(['role_id', 'skill_id']);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$roleId = (int) $this->getParam('role_id');
|
||||
$skillId = (int) $this->getParam('skill_id');
|
||||
$level = $this->clampLevel($this->getParam('required_level', 3));
|
||||
|
||||
$this->fetchRoleOrFail($roleId);
|
||||
$this->fetchVisibleSkillOrFail($skillId);
|
||||
|
||||
$existing = Database::fetchOne(
|
||||
'SELECT id FROM role_skills WHERE role_id = ? AND skill_id = ?',
|
||||
[$roleId, $skillId]
|
||||
);
|
||||
if ($existing) {
|
||||
Database::update('role_skills', ['required_level' => $level], 'id = ?', [(int) $existing['id']]);
|
||||
$id = (int) $existing['id'];
|
||||
$created = false;
|
||||
} else {
|
||||
$id = Database::insert('role_skills', [
|
||||
'organization_id' => $orgId,
|
||||
'role_id' => $roleId,
|
||||
'skill_id' => $skillId,
|
||||
'required_level' => $level,
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
$created = true;
|
||||
}
|
||||
|
||||
$this->logAudit('role_skill_set', 'role_skill', $id, ['role_id' => $roleId, 'skill_id' => $skillId, 'required_level' => $level]);
|
||||
$this->jsonSuccess(['id' => $id, 'required_level' => $level], $created ? 'Requisito aggiunto' : 'Requisito aggiornato', $created ? 201 : 200);
|
||||
}
|
||||
|
||||
/** DELETE /api/competences/roleSkills/{id} */
|
||||
public function removeRoleSkill(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$row = Database::fetchOne('SELECT id FROM role_skills WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
||||
if (!$row) {
|
||||
$this->jsonError('Requisito non trovato', 404, 'ROLE_SKILL_NOT_FOUND');
|
||||
}
|
||||
Database::delete('role_skills', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
||||
$this->logAudit('role_skill_removed', 'role_skill', $id, null);
|
||||
$this->jsonSuccess(null, 'Requisito rimosso');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// COMPETENZE POSSEDUTE (user_skills)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /api/competences/userSkills/{userId} — competenze possedute dall'utente. */
|
||||
public function userSkills(int $userId): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$this->assertMember($userId);
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT us.id, us.user_id, us.skill_id, us.level, us.acquired_via_course_id, us.evidence,
|
||||
s.name AS skill_name, s.area, tc.title AS course_title
|
||||
FROM user_skills us
|
||||
JOIN skills s ON s.id = us.skill_id
|
||||
LEFT JOIN training_courses tc ON tc.id = us.acquired_via_course_id
|
||||
WHERE us.user_id = ? AND us.organization_id = ?
|
||||
ORDER BY s.area, s.name',
|
||||
[$userId, $this->getCurrentOrgId()]
|
||||
);
|
||||
$this->jsonSuccess(array_map(fn($r) => [
|
||||
'id' => (int) $r['id'],
|
||||
'user_id' => (int) $r['user_id'],
|
||||
'skill_id' => (int) $r['skill_id'],
|
||||
'skill_name' => $r['skill_name'],
|
||||
'area' => $r['area'],
|
||||
'level' => (int) $r['level'],
|
||||
'acquired_via_course_id' => $r['acquired_via_course_id'] !== null ? (int) $r['acquired_via_course_id'] : null,
|
||||
'course_title' => $r['course_title'],
|
||||
'evidence' => $r['evidence'],
|
||||
], $rows));
|
||||
}
|
||||
|
||||
/** POST /api/competences/userSkills — {user_id, skill_id, level, evidence?, acquired_via_course_id?} upsert. */
|
||||
public function setUserSkill(): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$this->validateRequired(['user_id', 'skill_id']);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$userId = (int) $this->getParam('user_id');
|
||||
$skillId = (int) $this->getParam('skill_id');
|
||||
$level = $this->clampLevel($this->getParam('level', 1));
|
||||
|
||||
$this->assertMember($userId);
|
||||
$this->fetchVisibleSkillOrFail($skillId);
|
||||
$courseId = $this->validateCourse($this->getParam('acquired_via_course_id'));
|
||||
|
||||
$existing = Database::fetchOne(
|
||||
'SELECT id FROM user_skills WHERE organization_id = ? AND user_id = ? AND skill_id = ?',
|
||||
[$orgId, $userId, $skillId]
|
||||
);
|
||||
$data = [
|
||||
'level' => $level,
|
||||
'acquired_via_course_id' => $courseId,
|
||||
'evidence' => $this->nullableText($this->getParam('evidence'), 500),
|
||||
'assessed_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
if ($existing) {
|
||||
Database::update('user_skills', $data, 'id = ?', [(int) $existing['id']]);
|
||||
$id = (int) $existing['id'];
|
||||
$created = false;
|
||||
} else {
|
||||
$data['organization_id'] = $orgId;
|
||||
$data['user_id'] = $userId;
|
||||
$data['skill_id'] = $skillId;
|
||||
$data['created_by'] = $this->getCurrentUserId();
|
||||
$id = Database::insert('user_skills', $data);
|
||||
$created = true;
|
||||
}
|
||||
|
||||
$this->logAudit('user_skill_set', 'user_skill', $id, ['user_id' => $userId, 'skill_id' => $skillId, 'level' => $level]);
|
||||
$this->jsonSuccess(['id' => $id, 'level' => $level], $created ? 'Competenza registrata' : 'Competenza aggiornata', $created ? 201 : 200);
|
||||
}
|
||||
|
||||
/** DELETE /api/competences/userSkills/{id} */
|
||||
public function removeUserSkill(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$row = Database::fetchOne('SELECT id FROM user_skills WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
||||
if (!$row) {
|
||||
$this->jsonError('Competenza non trovata', 404, 'USER_SKILL_NOT_FOUND');
|
||||
}
|
||||
Database::delete('user_skills', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
||||
$this->logAudit('user_skill_removed', 'user_skill', $id, null);
|
||||
$this->jsonSuccess(null, 'Competenza rimossa');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// MAPPATURA COMPETENZA <-> CORSO (skill_course_map)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** POST /api/competences/skillCourses — {skill_id, training_course_id} */
|
||||
public function mapCourse(): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$this->validateRequired(['skill_id', 'training_course_id']);
|
||||
|
||||
$skillId = (int) $this->getParam('skill_id');
|
||||
$courseId = (int) $this->getParam('training_course_id');
|
||||
|
||||
$skill = $this->fetchVisibleSkillOrFail($skillId);
|
||||
$this->assertSkillWritable($skill);
|
||||
if ($this->validateCourse($courseId) === null) {
|
||||
$this->jsonError('Corso non valido per questa organizzazione', 422, 'INVALID_COURSE');
|
||||
}
|
||||
|
||||
$existing = Database::fetchOne(
|
||||
'SELECT id FROM skill_course_map WHERE skill_id = ? AND training_course_id = ?',
|
||||
[$skillId, $courseId]
|
||||
);
|
||||
if ($existing) {
|
||||
$this->jsonSuccess(['id' => (int) $existing['id']], 'Corso gia mappato', 200);
|
||||
}
|
||||
$id = Database::insert('skill_course_map', [
|
||||
'skill_id' => $skillId,
|
||||
'training_course_id' => $courseId,
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
$this->logAudit('skill_course_mapped', 'skill', $skillId, ['training_course_id' => $courseId]);
|
||||
$this->jsonSuccess(['id' => $id], 'Corso mappato', 201);
|
||||
}
|
||||
|
||||
/** DELETE /api/competences/skillCourses/{id} */
|
||||
public function unmapCourse(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
// Verifica che il mapping riguardi una skill gestibile dall'org corrente.
|
||||
$row = Database::fetchOne(
|
||||
'SELECT scm.id, s.organization_id
|
||||
FROM skill_course_map scm JOIN skills s ON s.id = scm.skill_id
|
||||
WHERE scm.id = ? AND (s.organization_id = ? OR s.organization_id IS NULL)',
|
||||
[$id, $this->getCurrentOrgId()]
|
||||
);
|
||||
if (!$row) {
|
||||
$this->jsonError('Mappatura non trovata', 404, 'MAP_NOT_FOUND');
|
||||
}
|
||||
$isGlobal = $row['organization_id'] === null;
|
||||
if ($isGlobal && ($this->currentUser['role'] ?? '') !== 'super_admin') {
|
||||
$this->jsonError('Le competenze globali sono gestite dal fornitore', 403, 'SKILL_GLOBAL_READONLY');
|
||||
}
|
||||
Database::delete('skill_course_map', 'id = ?', [$id]);
|
||||
$this->logAudit('skill_course_unmapped', 'skill', (int) ($row['organization_id'] ?? 0), ['map_id' => $id]);
|
||||
$this->jsonSuccess(null, 'Mappatura rimossa');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// GAP COMPETENZE + ALERT->AZIONE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/competences/gapGrid
|
||||
* Per ogni ruolo con titolare: competenze richieste vs possedute, gap,
|
||||
* corsi suggeriti e se esiste gia un'azione (NCR) aperta sul gap.
|
||||
*/
|
||||
public function gapGrid(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$org = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$orgId]);
|
||||
$entityType = $org['entity_type'] ?? 'not_applicable';
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT r.id AS role_id, r.role_name, r.holder_user_id, u.full_name AS holder_name,
|
||||
rs.id AS role_skill_id, rs.skill_id, s.name AS skill_name, s.area,
|
||||
rs.required_level, COALESCE(us.level, 0) AS current_level
|
||||
FROM org_roles r
|
||||
JOIN users u ON u.id = r.holder_user_id
|
||||
JOIN role_skills rs ON rs.role_id = r.id
|
||||
JOIN skills s ON s.id = rs.skill_id
|
||||
LEFT JOIN user_skills us
|
||||
ON us.user_id = r.holder_user_id AND us.skill_id = rs.skill_id AND us.organization_id = r.organization_id
|
||||
WHERE r.organization_id = ? AND r.holder_user_id IS NOT NULL
|
||||
ORDER BY r.sort_order, r.role_name, s.area, s.name',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
// Corsi suggeriti per skill (corsi visibili: org o globali).
|
||||
$maps = Database::fetchAll(
|
||||
'SELECT scm.skill_id, tc.id AS course_id, tc.title
|
||||
FROM skill_course_map scm JOIN training_courses tc ON tc.id = scm.training_course_id
|
||||
WHERE tc.organization_id = ? OR tc.organization_id IS NULL',
|
||||
[$orgId]
|
||||
);
|
||||
$coursesBySkill = [];
|
||||
foreach ($maps as $m) {
|
||||
$coursesBySkill[(int) $m['skill_id']][] = ['id' => (int) $m['course_id'], 'title' => $m['title']];
|
||||
}
|
||||
|
||||
// Azioni (NCR) gia aperte ancorate a un gap, indicizzate per role_skill_id.
|
||||
$openNcr = Database::fetchAll(
|
||||
"SELECT source_entity_id, id, ncr_code, status FROM non_conformities
|
||||
WHERE organization_id = ? AND source_entity_type = 'competence_gap'
|
||||
AND status NOT IN ('closed','cancelled')",
|
||||
[$orgId]
|
||||
);
|
||||
$ncrByRoleSkill = [];
|
||||
foreach ($openNcr as $n) {
|
||||
$ncrByRoleSkill[(int) $n['source_entity_id']] = ['ncr_id' => (int) $n['id'], 'ncr_code' => $n['ncr_code'], 'status' => $n['status']];
|
||||
}
|
||||
|
||||
$rolesById = [];
|
||||
$totalGaps = 0;
|
||||
foreach ($rows as $r) {
|
||||
$roleId = (int) $r['role_id'];
|
||||
if (!isset($rolesById[$roleId])) {
|
||||
$rolesById[$roleId] = [
|
||||
'role_id' => $roleId,
|
||||
'role_name' => $r['role_name'],
|
||||
'holder_user_id' => (int) $r['holder_user_id'],
|
||||
'holder_name' => $r['holder_name'],
|
||||
'skills' => [],
|
||||
'gap_count' => 0,
|
||||
];
|
||||
}
|
||||
$required = (int) $r['required_level'];
|
||||
$current = (int) $r['current_level'];
|
||||
$gap = max(0, $required - $current);
|
||||
$roleSkillId = (int) $r['role_skill_id'];
|
||||
if ($gap > 0) {
|
||||
$totalGaps++;
|
||||
$rolesById[$roleId]['gap_count']++;
|
||||
}
|
||||
$rolesById[$roleId]['skills'][] = [
|
||||
'role_skill_id' => $roleSkillId,
|
||||
'skill_id' => (int) $r['skill_id'],
|
||||
'skill_name' => $r['skill_name'],
|
||||
'area' => $r['area'],
|
||||
'required_level' => $required,
|
||||
'current_level' => $current,
|
||||
'gap' => $gap,
|
||||
'suggested_courses'=> $coursesBySkill[(int) $r['skill_id']] ?? [],
|
||||
'open_action' => $ncrByRoleSkill[$roleSkillId] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$roles = array_values($rolesById);
|
||||
$this->jsonSuccess([
|
||||
'entity_type' => $entityType,
|
||||
'pr_at_02_applies' => $entityType === 'essential',
|
||||
'roles' => $roles,
|
||||
'roles_with_holder' => count($roles),
|
||||
'roles_with_gaps' => count(array_filter($roles, fn($r) => $r['gap_count'] > 0)),
|
||||
'total_gaps' => $totalGaps,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/competences/openAction — {role_skill_id}
|
||||
* Apre una Non Conformita (NCR) ancorata al gap + una CAPA collegata,
|
||||
* riusando il workflow NCR/CAPA esistente. Idempotente: se esiste gia una
|
||||
* NCR aperta per lo stesso gap, ritorna quella (409).
|
||||
*/
|
||||
public function openAction(): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$this->validateRequired(['role_skill_id']);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
|
||||
$roleSkillId = (int) $this->getParam('role_skill_id');
|
||||
$rs = Database::fetchOne(
|
||||
'SELECT rs.id, rs.role_id, rs.skill_id, rs.required_level, s.name AS skill_name,
|
||||
r.role_name, r.holder_user_id
|
||||
FROM role_skills rs
|
||||
JOIN skills s ON s.id = rs.skill_id
|
||||
JOIN org_roles r ON r.id = rs.role_id
|
||||
WHERE rs.id = ? AND rs.organization_id = ?',
|
||||
[$roleSkillId, $orgId]
|
||||
);
|
||||
if (!$rs) {
|
||||
$this->jsonError('Requisito di competenza non trovato', 404, 'ROLE_SKILL_NOT_FOUND');
|
||||
}
|
||||
if ($rs['holder_user_id'] === null) {
|
||||
$this->jsonError('Il ruolo non ha un titolare: assegna prima un titolare', 422, 'ROLE_NO_HOLDER');
|
||||
}
|
||||
|
||||
$holderId = (int) $rs['holder_user_id'];
|
||||
$required = (int) $rs['required_level'];
|
||||
$cur = Database::fetchOne(
|
||||
'SELECT level FROM user_skills WHERE organization_id = ? AND user_id = ? AND skill_id = ?',
|
||||
[$orgId, $holderId, (int) $rs['skill_id']]
|
||||
);
|
||||
$current = $cur ? (int) $cur['level'] : 0;
|
||||
$gap = $required - $current;
|
||||
if ($gap <= 0) {
|
||||
$this->jsonError('Nessun gap di competenza su questo requisito', 422, 'NO_GAP');
|
||||
}
|
||||
|
||||
// Anti-duplicato: NCR gia aperta per lo stesso gap.
|
||||
$dup = Database::fetchOne(
|
||||
"SELECT id, ncr_code FROM non_conformities
|
||||
WHERE organization_id = ? AND source_entity_type = 'competence_gap' AND source_entity_id = ?
|
||||
AND status NOT IN ('closed','cancelled')",
|
||||
[$orgId, $roleSkillId]
|
||||
);
|
||||
if ($dup) {
|
||||
$this->jsonError('Esiste gia una non conformita aperta per questo gap (' . $dup['ncr_code'] . ')', 409, 'ACTION_EXISTS', [
|
||||
'ncr_id' => (int) $dup['id'],
|
||||
'ncr_code' => $dup['ncr_code'],
|
||||
]);
|
||||
}
|
||||
|
||||
// PR.AT-02 vale solo per i soggetti essenziali; PR.AT-01 per tutti.
|
||||
$org = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$orgId]);
|
||||
$prAt = (($org['entity_type'] ?? '') === 'essential') ? 'PR.AT-02' : 'PR.AT-01';
|
||||
$severity = $gap >= 2 ? 'major' : 'minor';
|
||||
|
||||
$title = 'Gap competenza: ' . $rs['skill_name'] . ' (' . $rs['role_name'] . ')';
|
||||
$desc = "Gap di competenza rilevato dall'organigramma.\n"
|
||||
. 'Ruolo: ' . $rs['role_name'] . "\n"
|
||||
. 'Competenza: ' . $rs['skill_name'] . "\n"
|
||||
. 'Livello richiesto: ' . $required . ' / Posseduto: ' . $current . ' (gap: ' . $gap . ")\n"
|
||||
. 'Riferimento: ' . $prAt . ' (formazione e consapevolezza); GV.RR-04 (cyber nelle pratiche HR). '
|
||||
. 'Gli obblighi di gestione del rischio fanno capo all\'art. 24 D.Lgs. 138/2024.';
|
||||
|
||||
Database::beginTransaction();
|
||||
try {
|
||||
$ncrCode = $this->generateCode('NCR');
|
||||
$ncrId = Database::insert('non_conformities', [
|
||||
'organization_id' => $orgId,
|
||||
'ncr_code' => $ncrCode,
|
||||
'title' => mb_substr($title, 0, 255),
|
||||
'description' => $desc,
|
||||
'source' => 'management_review',
|
||||
'source_entity_type'=> 'competence_gap',
|
||||
'source_entity_id' => $roleSkillId,
|
||||
'severity' => $severity,
|
||||
'category' => 'Competenze',
|
||||
'nis2_article' => $prAt,
|
||||
'status' => 'action_planned',
|
||||
'identified_by' => $this->getCurrentUserId(),
|
||||
'assigned_to' => $holderId,
|
||||
]);
|
||||
|
||||
$capaCode = $this->generateCode('CAPA');
|
||||
$capaId = Database::insert('capa_actions', [
|
||||
'ncr_id' => $ncrId,
|
||||
'organization_id' => $orgId,
|
||||
'capa_code' => $capaCode,
|
||||
'action_type' => 'corrective',
|
||||
'title' => mb_substr('Colmare il gap di competenza: ' . $rs['skill_name'], 0, 255),
|
||||
'description' => 'Pianificare formazione/affiancamento per portare ' . $rs['skill_name']
|
||||
. ' al livello ' . $required . '. Valutare i corsi mappati alla competenza.',
|
||||
'status' => 'planned',
|
||||
'responsible_user_id'=> $holderId,
|
||||
]);
|
||||
|
||||
Database::commit();
|
||||
} catch (Throwable $e) {
|
||||
Database::rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->logAudit('competence_action_opened', 'non_conformity', $ncrId, [
|
||||
'role_skill_id' => $roleSkillId, 'capa_id' => $capaId, 'gap' => $gap,
|
||||
]);
|
||||
$this->jsonSuccess([
|
||||
'ncr_id' => $ncrId,
|
||||
'ncr_code' => $ncrCode,
|
||||
'capa_id' => $capaId,
|
||||
'capa_code' => $capaCode,
|
||||
], 'Non conformita e azione correttiva create', 201);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PRIVATI
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Skill visibile all'org (org-owned o globale) oppure 404. */
|
||||
private function fetchVisibleSkillOrFail(int $id): array
|
||||
{
|
||||
$s = Database::fetchOne(
|
||||
'SELECT * FROM skills WHERE id = ? AND (organization_id = ? OR organization_id IS NULL)',
|
||||
[$id, $this->getCurrentOrgId()]
|
||||
);
|
||||
if (!$s) {
|
||||
$this->jsonError('Competenza non trovata', 404, 'SKILL_NOT_FOUND');
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
/** Una skill e scrivibile se appartiene all'org; le globali solo da super_admin. */
|
||||
private function assertSkillWritable(array $skill): void
|
||||
{
|
||||
$isGlobal = $skill['organization_id'] === null;
|
||||
if ($isGlobal && ($this->currentUser['role'] ?? '') !== 'super_admin') {
|
||||
$this->jsonError('Le competenze globali sono gestite dal fornitore', 403, 'SKILL_GLOBAL_READONLY');
|
||||
}
|
||||
}
|
||||
|
||||
/** Ruolo dell'organigramma nell'org corrente oppure 404 (anti-IDOR). */
|
||||
private function fetchRoleOrFail(int $id): array
|
||||
{
|
||||
$r = Database::fetchOne('SELECT * FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
||||
if (!$r) {
|
||||
$this->jsonError('Ruolo non trovato', 404, 'ROLE_NOT_FOUND');
|
||||
}
|
||||
return $r;
|
||||
}
|
||||
|
||||
/** L'utente deve essere membro dell'org corrente. */
|
||||
private function assertMember(int $userId): void
|
||||
{
|
||||
$m = Database::fetchOne(
|
||||
'SELECT id FROM user_organizations WHERE user_id = ? AND organization_id = ?',
|
||||
[$userId, $this->getCurrentOrgId()]
|
||||
);
|
||||
if (!$m) {
|
||||
$this->jsonError('Utente non membro dell organizzazione', 422, 'USER_NOT_MEMBER');
|
||||
}
|
||||
}
|
||||
|
||||
/** Corso visibile (org o globale) oppure null se non fornito; jsonError se id non valido. */
|
||||
private function validateCourse($raw): ?int
|
||||
{
|
||||
if ($raw === null || $raw === '' || (int) $raw === 0) {
|
||||
return null;
|
||||
}
|
||||
$courseId = (int) $raw;
|
||||
$c = Database::fetchOne(
|
||||
'SELECT id FROM training_courses WHERE id = ? AND (organization_id = ? OR organization_id IS NULL)',
|
||||
[$courseId, $this->getCurrentOrgId()]
|
||||
);
|
||||
if (!$c) {
|
||||
$this->jsonError('Corso non valido per questa organizzazione', 422, 'INVALID_COURSE');
|
||||
}
|
||||
return $courseId;
|
||||
}
|
||||
|
||||
private function clampLevel($raw): int
|
||||
{
|
||||
$n = (int) $raw;
|
||||
if ($n < 1) { $n = 1; }
|
||||
if ($n > 5) { $n = 5; }
|
||||
return $n;
|
||||
}
|
||||
|
||||
private function nullableText($v, int $max = 4000): ?string
|
||||
{
|
||||
if ($v === null) {
|
||||
return null;
|
||||
}
|
||||
$v = trim((string) $v);
|
||||
if ($v === '') {
|
||||
return null;
|
||||
}
|
||||
return mb_substr($v, 0, $max);
|
||||
}
|
||||
|
||||
private function normalizeSkill(array $s): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $s['id'],
|
||||
'organization_id' => $s['organization_id'] !== null ? (int) $s['organization_id'] : null,
|
||||
'is_global' => $s['organization_id'] === null,
|
||||
'name' => $s['name'],
|
||||
'area' => $s['area'],
|
||||
'description' => $s['description'] ?? null,
|
||||
'created_at' => $s['created_at'] ?? null,
|
||||
'updated_at' => $s['updated_at'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
# DESIGN — A4: Modello relazionale (organigramma RACI ↔ skill / formazione / inventario / procedure / rischi + scadenziario)
|
||||
|
||||
> Stato: **DESIGN (pre-implementazione)**. Contesto: segnalazioni Simon Fattori, punti **2, 3, 4, 5, 6, 7** (accolti in A4). Data: 2026-06-15.
|
||||
> Stato: **IN CORSO**. Contesto: segnalazioni Simon Fattori, punti **2, 3, 4, 5, 6, 7** (accolti in A4). Data: 2026-06-15.
|
||||
> È l'epic più grande → da realizzare a **FASI**. Ancoraggio normativo verificato (Det. ACN 164179/2025 + D.Lgs. 138/2024). Vedi `reference-dlgs-articles`, `DESIGN_A2_ONBOARDING.md`.
|
||||
>
|
||||
> **AVANZAMENTO**: ✅ **4.1 Organigramma** (migration `041_org_roles.sql`, `OrgRoleController`, `organigramma.html`) — LIVE. ✅ **4.2 Competenze** (migration `042_competences.sql`: `skills`/`role_skills`/`user_skills`/`skill_course_map`; `CompetenceController`; `competenze.html`) — LIVE. ⏳ 4.3 RACI · 4.4 scadenziario · 4.5 stakeholder.
|
||||
>
|
||||
> 🔴 **RETTIFICA NOMI REALI (verificata sul codice)**: questo design parlava di una tabella **`corrective_actions`** che **NON ESISTE**. La tabella delle azioni reale è **`capa_actions`** (mig.004), **figlia obbligatoria** di **`non_conformities`** (NCR) via `ncr_id NOT NULL`, e **non ha** una colonna `source_type`. L'alert "gap → azione" (impl. in 4.2) quindi **NON** crea `corrective_actions`/`source_type`: crea una **`non_conformities`** ancorata al gap (`source='management_review'`, `source_entity_type='competence_gap'`, `source_entity_id=role_skills.id`) + una **`capa_actions`** figlia. Le menzioni di `corrective_actions`/`source_type` qui sotto vanno lette in questo senso.
|
||||
|
||||
## 0. Principio
|
||||
A4 trasforma i moduli oggi "a silos" in un **grafo relazionale** con al centro l'**organigramma** (ruoli/responsabilità) e la **matrice RACI** come hub che lega i ruoli agli oggetti (inventario, procedure, rischi). Obiettivo: compliance *vissuta e dimostrabile in audit* (chi è responsabile di cosa, con quali competenze, con quali scadenze).
|
||||
@@ -31,7 +35,7 @@ A4 trasforma i moduli oggi "a silos" in un **grafo relazionale** con al centro l
|
||||
- **`raci_assignments`** — **MATRICE RACI** (hub): `id, organization_id, role_id → org_roles, object_type ENUM('inventory','procedure','risk','supplier'), object_id, raci ENUM('R','A','C','I')`. → lega l'organigramma a inventario/procedure/rischi/fornitori (m2m via questa tabella).
|
||||
- **link m2m diretti** (richiesti dalle segnalazioni 4/5/7): `procedure_inventory(procedure_id, asset_id)`, `procedure_risk(procedure_id, risk_id)`, `inventory_risk(asset_id, risk_id)`, `risk_measure(risk_id, measure_code)` (le misure ACN dell'assessment).
|
||||
- **`review_schedule`** — **SCADENZIARIO centralizzato**: `id, organization_id, entity_type ENUM('role','skill','inventory','procedure','risk','supplier','measure'), entity_id, owner_role_id NULL, frequency_months, last_reviewed_at, next_review_date, status ENUM('ok','due','overdue'), notes`. Aggrega/sostituisce le scadenze sparse.
|
||||
- **`actions`** (oppure riuso `corrective_actions`): azione generata da **alert** (gap competenza, scadenza, rischio) → `source_type, source_id, assigned_role_id, due_date, status`. *(Riuso consigliato di `corrective_actions` + un campo `source_type` per non duplicare il workflow CAPA.)*
|
||||
- **azione da alert** → **riuso `non_conformities` + `capa_actions`** (mig.004): l'alert (gap competenza, scadenza, rischio) crea una NCR ancorata via `source_entity_type`/`source_entity_id` (es. `'competence_gap'` + `role_skills.id`) e una `capa_actions` figlia. *(NON esiste `corrective_actions`/`source_type`: si riusa l'intero workflow CAPA senza alterarne lo schema.)*
|
||||
|
||||
**Relazioni chiave** (tutte m2m dove indicato dalle segnalazioni):
|
||||
```
|
||||
@@ -41,11 +45,11 @@ skills ──< skill_course_map >── training_courses
|
||||
procedures ──< procedure_inventory >── inventory ──< inventory_risk >── risks
|
||||
procedures ──< procedure_risk >── risks ──< risk_measure >── misure ACN (assessment)
|
||||
(role|skill|inventory|procedure|risk|supplier) ──1:N── review_schedule
|
||||
gap competenze / scadenze / rischi ──trigger──> actions(corrective_actions)
|
||||
gap competenze / scadenze / rischi ──trigger──> non_conformities ──< capa_actions (workflow CAPA)
|
||||
```
|
||||
|
||||
## 3. Gap competenze (segnalazione 3)
|
||||
**Gap = `role_skills.required_level` (del ruolo che l'utente ricopre) − `user_skills.level`** per ciascuna skill richiesta. Vista `competence_gap` (calcolata): per ogni utente con `org_roles.holder_user_id`, confronto richiesto vs posseduto. **Alert** quando gap > 0 → apertura **azione** (`corrective_actions`, `source_type='competence_gap'`) + voce nello **`review_schedule`**. Ancoraggio: **PR.AT-01** (tutti), **PR.AT-02** (solo essenziali), **GV.RR-04** (cyber nelle pratiche HR).
|
||||
**Gap = `role_skills.required_level` (del ruolo che l'utente ricopre) − `user_skills.level`** per ciascuna skill richiesta. Vista `competence_gap` (calcolata): per ogni utente con `org_roles.holder_user_id`, confronto richiesto vs posseduto. **Alert** quando gap > 0 → apertura **azione** = NCR (`non_conformities`, `source_entity_type='competence_gap'`, `source_entity_id=role_skills.id`) + `capa_actions` figlia (+ in futuro voce nello **`review_schedule`**). Ancoraggio: **PR.AT-01** (tutti), **PR.AT-02** (solo essenziali), **GV.RR-04** (cyber nelle pratiche HR).
|
||||
|
||||
## 4. Ancoraggio normativo per entità (must-have)
|
||||
- **Organigramma/ruoli** → **GV.RR-02** (ruoli, responsabilità e poteri stabiliti/comunicati) + **Art. 23 D.Lgs. 138/2024** (responsabilità organi di amministrazione e direttivi) → **`is_governance_body`** obbligatorio come nodo distinto con potere di approvazione.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
-- ============================================================================
|
||||
-- Migration 042 — A4 Fase 4.2: Competenze (skill / requisiti ruolo / competenze
|
||||
-- possedute / mappatura corsi) + base per gap competenze e alert->azione.
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 4 tabelle ADDITIVE. Idempotente (CREATE TABLE IF NOT EXISTS). Applicare con la
|
||||
-- STESSA connessione PDO dell'app (host MySQL servito da php-fpm), via runner.
|
||||
--
|
||||
-- Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md sez.3/4): PR.AT-01 (tutti),
|
||||
-- PR.AT-02 (solo soggetti essenziali), GV.RR-04 (cyber nelle pratiche HR).
|
||||
--
|
||||
-- NOTA architetturale: l'alert "gap competenza -> azione" RIUSA il workflow
|
||||
-- NCR/CAPA gia esistente (tabelle non_conformities + capa_actions, mig.004).
|
||||
-- NON esiste alcuna tabella 'corrective_actions' (nome errato nel design): le
|
||||
-- azioni sono capa_actions, figlie obbligatorie di una non_conformity. Quindi
|
||||
-- questa migration NON crea/altera tabelle azioni: ancora il gap alla NCR via
|
||||
-- non_conformities.source_entity_type='competence_gap' + source_entity_id.
|
||||
-- ============================================================================
|
||||
|
||||
-- Catalogo competenze. organization_id NULL = competenza globale di sistema
|
||||
-- (visibile a tutte le org, come i corsi globali).
|
||||
CREATE TABLE IF NOT EXISTS skills (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
organization_id INT NULL,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
area VARCHAR(100) NULL,
|
||||
description TEXT NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_skills_org (organization_id),
|
||||
INDEX idx_skills_area (area)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Competenze RICHIESTE da un ruolo dell'organigramma (m2m ruolo<->skill).
|
||||
CREATE TABLE IF NOT EXISTS role_skills (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
organization_id INT NOT NULL,
|
||||
role_id INT NOT NULL,
|
||||
skill_id INT NOT NULL,
|
||||
required_level TINYINT NOT NULL DEFAULT 3,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES org_roles(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uk_role_skill (role_id, skill_id),
|
||||
INDEX idx_role_skills_org (organization_id),
|
||||
INDEX idx_role_skills_skill (skill_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Competenze POSSEDUTE da un utente (m2m utente<->skill). acquired_via_course_id
|
||||
-- collega eventualmente la competenza al corso che l'ha erogata (registro corsi).
|
||||
CREATE TABLE IF NOT EXISTS user_skills (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
organization_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
skill_id INT NOT NULL,
|
||||
level TINYINT NOT NULL DEFAULT 1,
|
||||
acquired_via_course_id INT NULL,
|
||||
evidence VARCHAR(500) NULL,
|
||||
assessed_at DATETIME NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (acquired_via_course_id) REFERENCES training_courses(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uk_user_skill (organization_id, user_id, skill_id),
|
||||
INDEX idx_user_skills_user (user_id),
|
||||
INDEX idx_user_skills_skill (skill_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Mappatura competenza <-> corso che la eroga (m2m). Il corso puo essere globale
|
||||
-- (training_courses.organization_id NULL) o dell'org.
|
||||
CREATE TABLE IF NOT EXISTS skill_course_map (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
skill_id INT NOT NULL,
|
||||
training_course_id INT NOT NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (training_course_id) REFERENCES training_courses(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uk_skill_course (skill_id, training_course_id),
|
||||
INDEX idx_skill_course_course (training_course_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -70,9 +70,9 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della app: api.js + common.js (helper) + common-bi.js (override BI) -->
|
||||
<script src="js/api.js?v=20260617"></script>
|
||||
<script src="js/common.js?v=20260617"></script>
|
||||
<script src="js/common-bi.js?v=20260617"></script>
|
||||
<script src="js/api.js?v=20260618"></script>
|
||||
<script src="js/common.js?v=20260618"></script>
|
||||
<script src="js/common-bi.js?v=20260618"></script>
|
||||
<script>
|
||||
// Renderizza la sidebar BI (loadSidebar è stato ridefinito da common-bi.js)
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
+5
-5
@@ -152,8 +152,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -163,9 +163,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
const FUNC_LABELS = {
|
||||
|
||||
@@ -317,8 +317,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -328,8 +328,8 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -147,8 +147,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="../js/api.js?v=20260617"></script>
|
||||
<script src="../js/common.js?v=20260617"></script>
|
||||
<script src="../js/api.js?v=20260618"></script>
|
||||
<script src="../js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -158,8 +158,8 @@
|
||||
bootstrap.loadFonts('../vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="../js/common-bi.js?v=20260617"></script>
|
||||
<script src="../js/i18n.js?v=20260617"></script>
|
||||
<script src="../js/common-bi.js?v=20260618"></script>
|
||||
<script src="../js/i18n.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -169,8 +169,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="../js/api.js?v=20260617"></script>
|
||||
<script src="../js/common.js?v=20260617"></script>
|
||||
<script src="../js/api.js?v=20260618"></script>
|
||||
<script src="../js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -180,8 +180,8 @@
|
||||
bootstrap.loadFonts('../vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="../js/common-bi.js?v=20260617"></script>
|
||||
<script src="../js/i18n.js?v=20260617"></script>
|
||||
<script src="../js/common-bi.js?v=20260618"></script>
|
||||
<script src="../js/i18n.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -1088,10 +1088,10 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js?v=20260617"></script>
|
||||
<script src="js/common.js?v=20260617"></script>
|
||||
<script src="js/i18n.js?v=20260617"></script>
|
||||
<script src="js/help.js?v=20260617"></script>
|
||||
<script src="js/api.js?v=20260618"></script>
|
||||
<script src="js/common.js?v=20260618"></script>
|
||||
<script src="js/i18n.js?v=20260618"></script>
|
||||
<script src="js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
|
||||
@@ -154,8 +154,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -165,9 +165,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
+5
-5
@@ -366,8 +366,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -377,9 +377,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -349,14 +349,14 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js?v=20260617"></script>
|
||||
<script src="js/common.js?v=20260617"></script>
|
||||
<script src="js/api.js?v=20260618"></script>
|
||||
<script src="js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle + override sidebar BI (common-bi.js dopo common.js: ridefinisce solo loadSidebar) -->
|
||||
<script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script>if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }</script>
|
||||
<script src="js/common-bi.js?v=20260617"></script>
|
||||
<script src="js/i18n.js?v=20260617"></script>
|
||||
<script src="js/help.js?v=20260617"></script>
|
||||
<script src="js/common-bi.js?v=20260618"></script>
|
||||
<script src="js/i18n.js?v=20260618"></script>
|
||||
<script src="js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Competenze - NIS2 Agile</title>
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260617">
|
||||
<style>
|
||||
.cmp-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:16px; font-size:.92rem; line-height:1.6; }
|
||||
.cmp-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 18px; }
|
||||
.cmp-tabs { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:18px; }
|
||||
.cmp-tab { flex:1 1 160px; min-width:140px; padding:10px 12px; border:1.5px solid var(--gray-200,#e5e7eb); border-radius:10px; background:#fff; cursor:pointer; font-size:.85rem; font-weight:600; text-align:center; }
|
||||
.cmp-tab.active { border-color:var(--primary,#2563eb); background:#eff6ff; color:var(--primary,#2563eb); }
|
||||
.cmp-panel { display:none; }
|
||||
.cmp-panel.active { display:block; }
|
||||
.cmp-table { width:100%; border-collapse:collapse; font-size:.88rem; }
|
||||
.cmp-table th, .cmp-table td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--gray-100,#f3f4f6); vertical-align:top; }
|
||||
.cmp-table th { font-size:.74rem; text-transform:uppercase; letter-spacing:.04em; color:var(--gray-500,#6b7280); }
|
||||
.cmp-badge { display:inline-block; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:6px; }
|
||||
.cmp-badge.global { background:#ede9fe; color:#5b21b6; }
|
||||
.cmp-badge.area { background:#f1f5f9; color:#475569; }
|
||||
.cmp-badge.lvl { background:#e0f2fe; color:#075985; }
|
||||
.cmp-toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
|
||||
.cmp-toolbar select, .cmp-inline select, .cmp-inline input { padding:8px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.88rem; }
|
||||
.cmp-inline { display:flex; gap:8px; flex-wrap:wrap; align-items:flex-end; background:#f9fafb; border:1px solid var(--gray-100,#f3f4f6); border-radius:10px; padding:12px; margin-bottom:14px; }
|
||||
.cmp-inline .fld { display:flex; flex-direction:column; gap:3px; }
|
||||
.cmp-inline .fld label { font-size:.72rem; font-weight:600; color:var(--gray-500,#6b7280); }
|
||||
.cmp-actions-cell { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.cmp-actions-cell .btn, .cmp-toolbar .btn { padding:4px 9px; font-size:.76rem; }
|
||||
/* Gap cards */
|
||||
.gap-role { border:1px solid var(--gray-200,#e5e7eb); border-radius:10px; margin-bottom:14px; overflow:hidden; }
|
||||
.gap-role-head { background:var(--gray-50,#f9fafb); padding:11px 14px; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:6px; }
|
||||
.gap-role-title { font-weight:700; }
|
||||
.gap-role-sub { font-size:.82rem; color:var(--gray-500,#6b7280); }
|
||||
.gap-row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; padding:10px 14px; border-top:1px solid var(--gray-100,#f3f4f6); }
|
||||
.gap-row .skill { flex:1 1 220px; font-weight:600; font-size:.9rem; }
|
||||
.gap-meter { font-size:.82rem; }
|
||||
.gap-pill { display:inline-block; font-size:.72rem; font-weight:800; padding:2px 9px; border-radius:20px; }
|
||||
.gap-pill.ok { background:#dcfce7; color:#166534; }
|
||||
.gap-pill.bad { background:#fee2e2; color:#991b1b; }
|
||||
.gap-row .ctas { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.gap-row .ctas .btn { padding:4px 9px; font-size:.76rem; }
|
||||
.cmp-empty { text-align:center; padding:34px 16px; color:var(--gray-500,#6b7280); }
|
||||
/* Modale */
|
||||
.cm-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; }
|
||||
.cm-overlay.open { display:flex; }
|
||||
.cm-dialog { background:#fff; border-radius:12px; width:100%; max-width:560px; box-shadow:0 20px 50px rgba(0,0,0,.25); }
|
||||
.cm-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100,#f3f4f6); }
|
||||
.cm-head h3 { margin:0; font-size:1.05rem; }
|
||||
.cm-body { padding:18px 20px; }
|
||||
.cm-foot { padding:14px 20px; border-top:1px solid var(--gray-100,#f3f4f6); display:flex; justify-content:flex-end; gap:10px; }
|
||||
.cm-field { margin-bottom:14px; }
|
||||
.cm-field label { display:block; font-weight:600; font-size:.86rem; margin-bottom:4px; }
|
||||
.cm-field input[type=text], .cm-field select, .cm-field textarea { width:100%; padding:9px 10px; border:1px solid var(--gray-200,#e5e7eb); border-radius:8px; font-size:.9rem; }
|
||||
.cm-field textarea { min-height:64px; resize:vertical; }
|
||||
.cm-err { color:#b91c1c; font-size:.82rem; min-height:18px; }
|
||||
.cm-close { background:none; border:none; font-size:1.4rem; line-height:1; cursor:pointer; color:var(--gray-400,#9ca3af); }
|
||||
.cm-maplist { margin-top:10px; }
|
||||
.cm-maprow { display:flex; justify-content:space-between; align-items:center; padding:7px 0; border-top:1px solid var(--gray-100,#f3f4f6); font-size:.86rem; }
|
||||
</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="comp.title">Competenze</h2>
|
||||
<div class="content-header-actions">
|
||||
<span class="savehint" id="cmp-hint" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-body">
|
||||
<div class="cmp-intro">
|
||||
<strong>Competenze, requisiti e gap.</strong>
|
||||
Definisci le competenze necessarie, assegnale ai ruoli dell'organigramma, registra quelle possedute dalle persone e individua i divari (gap) da colmare con formazione o azioni correttive.
|
||||
</div>
|
||||
<div class="cmp-note">
|
||||
Ancoraggio: <strong>PR.AT-01</strong> (formazione/consapevolezza per tutti), <strong>PR.AT-02</strong> (solo soggetti essenziali), <strong>GV.RR-04</strong> (cybersicurezza nelle pratiche HR). Gli obblighi di gestione del rischio fanno capo all'<strong>art. 24 D.Lgs. 138/2024</strong>. Strumento di supporto, non un parere legale.
|
||||
</div>
|
||||
|
||||
<div class="cmp-tabs" id="cmp-tabs">
|
||||
<div class="cmp-tab active" data-tab="catalog" onclick="cmpTab('catalog')">Catalogo competenze</div>
|
||||
<div class="cmp-tab" data-tab="roles" onclick="cmpTab('roles')">Requisiti per ruolo</div>
|
||||
<div class="cmp-tab" data-tab="users" onclick="cmpTab('users')">Competenze persone</div>
|
||||
<div class="cmp-tab" data-tab="gap" onclick="cmpTab('gap')">Gap & azioni</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 1 — Catalogo competenze -->
|
||||
<div class="cmp-panel active" id="panel-catalog">
|
||||
<div class="cmp-toolbar">
|
||||
<button class="btn btn-primary btn-sm" onclick="cmpOpenSkill()">+ Nuova competenza</button>
|
||||
<span class="gap-role-sub" id="cmp-catalog-count"></span>
|
||||
</div>
|
||||
<div id="cmp-catalog"></div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 2 — Requisiti per ruolo -->
|
||||
<div class="cmp-panel" id="panel-roles">
|
||||
<div class="cmp-toolbar">
|
||||
<label for="cmp-role-sel" style="font-weight:600;font-size:.86rem;">Ruolo:</label>
|
||||
<select id="cmp-role-sel" onchange="cmpLoadRoleSkills()"></select>
|
||||
</div>
|
||||
<div class="cmp-inline" id="cmp-role-add" style="display:none;">
|
||||
<div class="fld"><label>Competenza</label><select id="cmp-rs-skill"></select></div>
|
||||
<div class="fld"><label>Livello richiesto</label><select id="cmp-rs-level"></select></div>
|
||||
<button class="btn btn-primary btn-sm" onclick="cmpAddRoleSkill()">Aggiungi requisito</button>
|
||||
</div>
|
||||
<div id="cmp-role-skills"></div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 3 — Competenze persone -->
|
||||
<div class="cmp-panel" id="panel-users">
|
||||
<div class="cmp-toolbar">
|
||||
<label for="cmp-user-sel" style="font-weight:600;font-size:.86rem;">Persona:</label>
|
||||
<select id="cmp-user-sel" onchange="cmpLoadUserSkills()"></select>
|
||||
</div>
|
||||
<div class="cmp-inline" id="cmp-user-add" style="display:none;">
|
||||
<div class="fld"><label>Competenza</label><select id="cmp-us-skill"></select></div>
|
||||
<div class="fld"><label>Livello posseduto</label><select id="cmp-us-level"></select></div>
|
||||
<div class="fld"><label>Corso (opz.)</label><select id="cmp-us-course"></select></div>
|
||||
<div class="fld"><label>Evidenza (opz.)</label><input type="text" id="cmp-us-evidence" maxlength="500" placeholder="es. attestato, data"></div>
|
||||
<button class="btn btn-primary btn-sm" onclick="cmpAddUserSkill()">Registra</button>
|
||||
</div>
|
||||
<div id="cmp-user-skills"></div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 4 — Gap & azioni -->
|
||||
<div class="cmp-panel" id="panel-gap">
|
||||
<div class="cmp-toolbar">
|
||||
<button class="btn btn-outline btn-sm" onclick="cmpLoadGap()">Ricalcola gap</button>
|
||||
<span class="gap-role-sub" id="cmp-gap-summary"></span>
|
||||
</div>
|
||||
<div id="cmp-gap"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modale competenza (crea/modifica) -->
|
||||
<div class="cm-overlay" id="cmp-skill-modal" role="dialog" aria-modal="true" aria-labelledby="cmp-skill-title">
|
||||
<div class="cm-dialog">
|
||||
<div class="cm-head"><h3 id="cmp-skill-title">Nuova competenza</h3><button class="cm-close" type="button" onclick="cmpClose('cmp-skill-modal')" aria-label="Chiudi">×</button></div>
|
||||
<div class="cm-body">
|
||||
<input type="hidden" id="cmp-skill-id">
|
||||
<div class="cm-field"><label for="cmp-skill-name">Nome *</label><input type="text" id="cmp-skill-name" maxlength="150" placeholder="es. Gestione incidenti di sicurezza"></div>
|
||||
<div class="cm-field"><label for="cmp-skill-area">Area</label><input type="text" id="cmp-skill-area" maxlength="100" placeholder="es. Tecnica, Governance, Legale, Awareness"></div>
|
||||
<div class="cm-field"><label for="cmp-skill-desc">Descrizione</label><textarea id="cmp-skill-desc" maxlength="2000"></textarea></div>
|
||||
<div class="cm-err" id="cmp-skill-err" role="alert"></div>
|
||||
</div>
|
||||
<div class="cm-foot"><button class="btn btn-outline" type="button" onclick="cmpClose('cmp-skill-modal')">Annulla</button><button class="btn btn-primary" type="button" id="cmp-skill-save" onclick="cmpSaveSkill()">Salva</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modale mappatura corsi -->
|
||||
<div class="cm-overlay" id="cmp-courses-modal" role="dialog" aria-modal="true" aria-labelledby="cmp-courses-title">
|
||||
<div class="cm-dialog">
|
||||
<div class="cm-head"><h3 id="cmp-courses-title">Corsi che erogano la competenza</h3><button class="cm-close" type="button" onclick="cmpClose('cmp-courses-modal')" aria-label="Chiudi">×</button></div>
|
||||
<div class="cm-body">
|
||||
<input type="hidden" id="cmp-courses-skill-id">
|
||||
<div class="cm-field" style="display:flex;gap:8px;align-items:flex-end;">
|
||||
<div style="flex:1;"><label for="cmp-courses-sel">Aggiungi corso</label><select id="cmp-courses-sel"></select></div>
|
||||
<button class="btn btn-primary" type="button" onclick="cmpMapCourse()">Aggiungi</button>
|
||||
</div>
|
||||
<div class="cm-maplist" id="cmp-courses-list"></div>
|
||||
<div class="cm-err" id="cmp-courses-err" role="alert"></div>
|
||||
</div>
|
||||
<div class="cm-foot"><button class="btn btn-outline" type="button" onclick="cmpClose('cmp-courses-modal')">Chiudi</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script src="/js/competenze.js?v=20260617"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -382,8 +382,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -393,8 +393,8 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script>
|
||||
const SPRITE = '/vendor/bootstrap-italia/dist/svg/sprites.svg';
|
||||
function ico(name, cls) { return `<svg class="ico ${cls||''}" aria-hidden="true"><use href="${SPRITE}#${name}"></use></svg>`; }
|
||||
|
||||
@@ -143,8 +143,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -154,9 +154,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della forgot-password.html: ZERO modifiche backend -->
|
||||
<script src="js/api.js?v=20260617"></script>
|
||||
<script src="js/api.js?v=20260618"></script>
|
||||
<script>
|
||||
const form = document.getElementById('forgot-form');
|
||||
const err = document.getElementById('err');
|
||||
|
||||
+5
-5
@@ -1142,8 +1142,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (accordion) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. -->
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
@@ -1152,9 +1152,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
// La guida è accessibile anche da non autenticati (utile per onboarding)
|
||||
// ma se sei loggato carichi sidebar + i18n normalmente. Stessa logica
|
||||
|
||||
@@ -351,8 +351,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -362,9 +362,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth & Init ──────────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -87,6 +87,7 @@ $controllerMap = [
|
||||
'auth' => 'AuthController',
|
||||
'organizations' => 'OrganizationController',
|
||||
'org-roles' => 'OrgRoleController', // A4 Fase 4.1 — Organigramma (ruoli/gerarchia, Art.23 governance)
|
||||
'competences' => 'CompetenceController', // A4 Fase 4.2 — Competenze (skill, requisiti ruolo, gap, alert->azione)
|
||||
'assessments' => 'AssessmentController',
|
||||
'acn-gap' => 'AcnAssessmentController', // Gap Analysis ACN (Det. 164179/2025, misure/requisiti)
|
||||
'isms' => 'IsmsModelController', // Modello Organizzativo SGSI (ISO 27001/27017/27018 + SoA)
|
||||
@@ -204,6 +205,24 @@ $actionMap = [
|
||||
'DELETE:{id}' => 'delete',
|
||||
],
|
||||
|
||||
// ── CompetenceController — Competenze (A4 Fase 4.2) ──
|
||||
'competences' => [
|
||||
'GET:catalog' => 'catalog',
|
||||
'POST:skills' => 'createSkill',
|
||||
'PUT:skills/{subId}' => 'updateSkill',
|
||||
'DELETE:skills/{subId}' => 'deleteSkill',
|
||||
'GET:roleSkills/{subId}' => 'roleSkills', // {subId} = role_id
|
||||
'POST:roleSkills' => 'setRoleSkill',
|
||||
'DELETE:roleSkills/{subId}' => 'removeRoleSkill',
|
||||
'GET:userSkills/{subId}' => 'userSkills', // {subId} = user_id
|
||||
'POST:userSkills' => 'setUserSkill',
|
||||
'DELETE:userSkills/{subId}' => 'removeUserSkill',
|
||||
'POST:skillCourses' => 'mapCourse',
|
||||
'DELETE:skillCourses/{subId}' => 'unmapCourse',
|
||||
'GET:gapGrid' => 'gapGrid',
|
||||
'POST:openAction' => 'openAction',
|
||||
],
|
||||
|
||||
// ── AssessmentController ────────────────────────
|
||||
'assessments' => [
|
||||
'GET:list' => 'list',
|
||||
|
||||
@@ -910,8 +910,8 @@ curl -H <span class="str">"X-API-Key: nis2_TUA_CHIAVE"</span> \
|
||||
|
||||
</main>
|
||||
|
||||
<script src="js/common.js?v=20260617"></script>
|
||||
<script src="js/i18n.js?v=20260617"></script>
|
||||
<script src="js/common.js?v=20260618"></script>
|
||||
<script src="js/i18n.js?v=20260618"></script>
|
||||
<script>
|
||||
function showTab(id) {
|
||||
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
||||
|
||||
+5
-5
@@ -184,8 +184,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -195,9 +195,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script src="/js/isms.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
+5
-5
@@ -151,8 +151,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Stessa logica JS della pagina live (parita' funzionale assoluta, zero backend). -->
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. -->
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
@@ -161,9 +161,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script src="/js/kb.js"></script>
|
||||
<script>
|
||||
// Gate auth + chrome come le altre pagine -bi.
|
||||
|
||||
@@ -515,7 +515,7 @@ body { background: var(--bg-main); }
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script src="js/api.js?v=20260617"></script>
|
||||
<script src="js/api.js?v=20260618"></script>
|
||||
<script>
|
||||
// ══════════════════════════════════════════════════════
|
||||
// CONFIG
|
||||
|
||||
+2
-2
@@ -94,8 +94,8 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della login attuale: ZERO modifiche backend -->
|
||||
<script src="js/api.js?v=20260617"></script>
|
||||
<script src="js/common.js?v=20260617"></script>
|
||||
<script src="js/api.js?v=20260618"></script>
|
||||
<script src="js/common.js?v=20260618"></script>
|
||||
<script>
|
||||
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
|
||||
|
||||
|
||||
@@ -424,6 +424,6 @@ curl https://nis2.agile.software/api/services/status</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="js/common.js?v=20260617"></script>
|
||||
<script src="js/common.js?v=20260618"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -112,8 +112,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -123,9 +123,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
|
||||
@@ -494,8 +494,8 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della onboarding.html: ZERO modifiche backend -->
|
||||
<script src="js/api.js?v=20260617"></script>
|
||||
<script src="js/common.js?v=20260617"></script>
|
||||
<script src="js/api.js?v=20260618"></script>
|
||||
<script src="js/common.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth check ──────────────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -138,12 +138,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script src="/js/organigramma.js?v=20260617"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -333,8 +333,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -344,9 +344,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -268,8 +268,8 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della register.html: ZERO modifiche backend -->
|
||||
<script src="js/api.js?v=20260617"></script>
|
||||
<script src="js/common.js?v=20260617"></script>
|
||||
<script src="js/api.js?v=20260618"></script>
|
||||
<script src="js/common.js?v=20260618"></script>
|
||||
<script>
|
||||
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
|
||||
|
||||
|
||||
+5
-5
@@ -443,8 +443,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -454,9 +454,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
+5
-5
@@ -494,8 +494,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -505,9 +505,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth & Init ──────────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -672,8 +672,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -683,9 +683,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -165,8 +165,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js?v=20260617"></script>
|
||||
<script src="js/common.js?v=20260617"></script>
|
||||
<script src="js/api.js?v=20260618"></script>
|
||||
<script src="js/common.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -477,8 +477,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -488,9 +488,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
* Il nome cache include la release: va BUMPATO ad ogni rilascio (vedi version.json),
|
||||
* cosi' activate elimina automaticamente la shell vecchia.
|
||||
*/
|
||||
const CACHE = 'nis2-shell-v1.15.0';
|
||||
const CACHE = 'nis2-shell-v1.17.0';
|
||||
|
||||
const PRECACHE = [
|
||||
'/offline.html',
|
||||
|
||||
@@ -292,8 +292,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -303,9 +303,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":"1.16.0","build":"2026-06-15-v1.16.0","date":"2026-06-15","changelog":"A4 Fase 4.1 - Organigramma: nuovo modulo per mappare ruoli, gerarchia e titolari, con il nodo distinto degli organi di amministrazione/direttivi (art.23 D.Lgs.138/2024, GV.RR-02). Backend OrgRoleController (CRUD, multi-tenancy, anti-IDOR, prevenzione cicli), tabella org_roles (migration 041, additiva), pagina organigramma.html con vista ad albero ed editor, help contestuale e voce di menu. Base del modello relazionale A4 (RACI/skill/scadenziario nelle fasi successive)."}
|
||||
{"version":"1.17.0","build":"2026-06-15-v1.17.0","date":"2026-06-15","changelog":"A4 Fase 4.2 - Competenze: nuovo modulo per catalogo competenze, requisiti per ruolo, competenze possedute dalle persone, mappatura competenza-corso, calcolo gap competenze e apertura azione correttiva dal gap (riuso workflow NCR/CAPA). Backend CompetenceController + tabelle skills/role_skills/user_skills/skill_course_map (migration 042, additiva). Pagina competenze.html a 4 schede, help contestuale, voce di menu. Ancoraggio PR.AT-01/02, GV.RR-04, art.24 D.Lgs.138/2024. PWA cache bump v1.17.0."}
|
||||
|
||||
@@ -207,8 +207,8 @@
|
||||
|
||||
<!-- Report Detail Modal handled by common.js showModal -->
|
||||
|
||||
<script src="/js/api.js?v=20260617"></script>
|
||||
<script src="/js/common.js?v=20260617"></script>
|
||||
<script src="/js/api.js?v=20260618"></script>
|
||||
<script src="/js/common.js?v=20260618"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -218,9 +218,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260617"></script>
|
||||
<script src="/js/i18n.js?v=20260617"></script>
|
||||
<script src="/js/help.js?v=20260617"></script>
|
||||
<script src="/js/common-bi.js?v=20260618"></script>
|
||||
<script src="/js/i18n.js?v=20260618"></script>
|
||||
<script src="/js/help.js?v=20260618"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
|
||||
@@ -319,9 +319,9 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js?v=20260617"></script>
|
||||
<script src="js/common.js?v=20260617"></script>
|
||||
<script src="js/i18n.js?v=20260617"></script>
|
||||
<script src="js/api.js?v=20260618"></script>
|
||||
<script src="js/common.js?v=20260618"></script>
|
||||
<script src="js/i18n.js?v=20260618"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
|
||||
+17
-7
@@ -18,7 +18,8 @@ require_once __DIR__ . '/../application/config/database.php';
|
||||
|
||||
$files = [
|
||||
__DIR__ . '/../docs/sql/041_org_roles.sql', // Fase 4.1 — organigramma
|
||||
// Le fasi 4.2-4.5 aggiungeranno qui i rispettivi file (042, 043, ...).
|
||||
__DIR__ . '/../docs/sql/042_competences.sql', // Fase 4.2 — competenze
|
||||
// Le fasi 4.3-4.5 aggiungeranno qui i rispettivi file (043, ...).
|
||||
];
|
||||
|
||||
$pdo = Database::getInstance();
|
||||
@@ -63,15 +64,24 @@ foreach ($files as $file) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verifica rapida: la tabella org_roles esiste e ha le colonne attese.
|
||||
echo "\n[OK] Eseguiti $totalStmts statement.\n";
|
||||
|
||||
// Verifica rapida: tabella org_roles (4.1) e le 4 tabelle competenze (4.2).
|
||||
$cols = $pdo->query("SHOW COLUMNS FROM org_roles")->fetchAll(PDO::FETCH_COLUMN, 0);
|
||||
$expected = ['id','organization_id','role_name','parent_role_id','holder_user_id','is_governance_body','description','sort_order','created_by','created_at','updated_at'];
|
||||
$missing = array_diff($expected, $cols);
|
||||
|
||||
echo "\n[OK] Eseguiti $totalStmts statement.\n";
|
||||
echo "[OK] org_roles colonne: " . implode(', ', $cols) . "\n";
|
||||
if ($missing) {
|
||||
fwrite(STDERR, "[ATTENZIONE] colonne mancanti: " . implode(', ', $missing) . "\n");
|
||||
fwrite(STDERR, "[ATTENZIONE] org_roles colonne mancanti: " . implode(', ', $missing) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "[OK] org_roles pronta (organigramma A4 Fase 4.1).\n";
|
||||
echo "[OK] org_roles pronta (A4 Fase 4.1).\n";
|
||||
|
||||
foreach (['skills','role_skills','user_skills','skill_course_map'] as $t) {
|
||||
$c = $pdo->query("SHOW COLUMNS FROM `$t`")->fetchAll(PDO::FETCH_COLUMN, 0);
|
||||
if (!$c) {
|
||||
fwrite(STDERR, "[ATTENZIONE] tabella mancante: $t\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "[OK] $t pronta (" . count($c) . " colonne).\n";
|
||||
}
|
||||
echo "[OK] Competenze pronte (A4 Fase 4.2).\n";
|
||||
|
||||
Reference in New Issue
Block a user