getConnection();
/* ==========================================
PERMISSIONS
========================================== */
$isHrManager = Auth::user()->hasRole('Admin')
|| Auth::user()->hasRole('Superuser')
|| Auth::user()->hasRole('employee-hr')
|| Auth::user()->hasRole('manager');
if (!$isHrManager) {
header('Location: employee-profile.php');
exit;
}
/* ==========================================
FILTERS (from GET)
========================================== */
$fEmployeeId = isset($_GET['employee_id']) && $_GET['employee_id'] !== '' ? (int)$_GET['employee_id'] : 0;
$fTopicId = isset($_GET['topic_id']) && $_GET['topic_id'] !== '' ? (int)$_GET['topic_id'] : 0;
$fStatus = isset($_GET['status']) ? trim($_GET['status']) : '';
$fType = isset($_GET['type']) ? trim($_GET['type']) : '';
$fDepartmentId = isset($_GET['department_id']) && $_GET['department_id'] !== '' ? (int)$_GET['department_id'] : 0;
/* ==========================================
LOAD DATA
========================================== */
$where = [];
$params = [];
// Only the most recent record per (employee, topic) — older initial/refresher
// rows stay as history on the employee profile, not in this overview.
$where[] = "NOT EXISTS (
SELECT 1 FROM employee_trainings et2
WHERE et2.employee_id = et.employee_id
AND et2.training_topic_id = et.training_topic_id
AND (et2.completed_date > et.completed_date
OR (et2.completed_date = et.completed_date AND et2.id > et.id))
)";
if ($fEmployeeId > 0) {
$where[] = 'et.employee_id = :eid';
$params['eid'] = $fEmployeeId;
}
if ($fTopicId > 0) {
$where[] = 'et.training_topic_id = :tid';
$params['tid'] = $fTopicId;
}
if ($fType !== '' && in_array($fType, ['initial', 'refresher'], true)) {
$where[] = 'et.training_type = :ty';
$params['ty'] = $fType;
}
if ($fDepartmentId > 0) {
$where[] = 'e.department_id = :did';
$params['did'] = $fDepartmentId;
}
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
$stmt = $pdo->prepare("
SELECT et.*,
tt.name AS topic_name,
tt.default_reminder_days AS topic_default_rem,
e.first_name, e.last_name, e.employee_code,
d.name AS department_name, d.color AS department_color,
(SELECT COUNT(*) FROM employee_training_attachments a WHERE a.training_id = et.id) AS attachments_count
FROM employee_trainings et
JOIN training_topics tt ON tt.id = et.training_topic_id
JOIN employees e ON e.id = et.employee_id
LEFT JOIN departments d ON d.id = e.department_id
$whereSql
ORDER BY et.next_due_date IS NULL, et.next_due_date ASC, e.last_name, e.first_name
");
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
require_once __DIR__ . '/include/training_coverage.php';
/* Mappa di tutti i corsi (serve all'helper coperture) */
$allTopicsById = [];
foreach ($pdo->query("SELECT id, name, default_frequency_months, default_reminder_days, is_active FROM training_topics")->fetchAll(PDO::FETCH_ASSOC) as $t) {
if ((int)$t['is_active'] !== 1) continue;
$allTopicsById[(int)$t['id']] = $t;
}
/* Filter by computed status */
function trainingStatus(?string $nextDue, ?int $reminderDays, ?int $topicDefaultRem): array
{
if (!$nextDue) {
return ['code' => 'compliant', 'label' => 'Conforme', 'class' => 'success'];
}
$rem = $reminderDays !== null ? $reminderDays : ($topicDefaultRem !== null ? $topicDefaultRem : 30);
$today = new DateTime('today');
$due = DateTime::createFromFormat('Y-m-d', $nextDue);
if (!$due) return ['code' => 'compliant', 'label' => 'Conforme', 'class' => 'success'];
$daysLeft = (int)$today->diff($due)->format('%r%a');
if ($daysLeft < 0) return ['code' => 'expired', 'label' => 'Scaduto', 'class' => 'danger', 'days' => $daysLeft];
if ($daysLeft <= $rem) return ['code' => 'due_soon', 'label' => 'Da aggiornare', 'class' => 'warning', 'days' => $daysLeft];
return ['code' => 'compliant', 'label' => 'Conforme', 'class' => 'success', 'days' => $daysLeft];
}
/* ==========================================
COPERTURE — righe virtuali derivate.
Vanno calcolate sui record NON filtrati per topic,
altrimenti filtrando su "Antincendio" perderemmo l'RSPP che lo copre.
========================================== */
$coverageBase = $rows;
if ($fTopicId > 0) {
$baseWhere = ["NOT EXISTS (
SELECT 1 FROM employee_trainings et2
WHERE et2.employee_id = et.employee_id
AND et2.training_topic_id = et.training_topic_id
AND (et2.completed_date > et.completed_date
OR (et2.completed_date = et.completed_date AND et2.id > et.id))
)"];
$baseParams = [];
if ($fEmployeeId > 0) {
$baseWhere[] = 'et.employee_id = :eid';
$baseParams['eid'] = $fEmployeeId;
}
if ($fType !== '' && in_array($fType, ['initial', 'refresher'], true)) {
$baseWhere[] = 'et.training_type = :ty';
$baseParams['ty'] = $fType;
}
if ($fDepartmentId > 0) {
$baseWhere[] = 'e.department_id = :did';
$baseParams['did'] = $fDepartmentId;
}
$baseSql = 'WHERE ' . implode(' AND ', $baseWhere);
$bstmt = $pdo->prepare("
SELECT et.*, tt.name AS topic_name, tt.default_reminder_days AS topic_default_rem,
e.first_name, e.last_name, e.employee_code,
d.name AS department_name, d.color AS department_color
FROM employee_trainings et
JOIN training_topics tt ON tt.id = et.training_topic_id
JOIN employees e ON e.id = et.employee_id
LEFT JOIN departments d ON d.id = e.department_id
$baseSql
");
$bstmt->execute($baseParams);
$coverageBase = $bstmt->fetchAll(PDO::FETCH_ASSOC);
}
$virtualRows = tc_buildVirtualRows($pdo, $coverageBase, $allTopicsById);
/* Applica i filtri attivi alle righe virtuali */
if ($fTopicId > 0) {
$virtualRows = array_values(array_filter($virtualRows, function ($v) use ($fTopicId) {
return (int)$v['training_topic_id'] === $fTopicId;
}));
}
$coveredKeys = tc_coveredKeys($virtualRows);
$filtered = [];
$counters = ['compliant' => 0, 'due_soon' => 0, 'expired' => 0, 'not_present' => 0, 'all' => 0];
foreach ($rows as $r) {
$s = trainingStatus(
$r['next_due_date'] ?: null,
$r['reminder_days'] !== null ? (int)$r['reminder_days'] : null,
$r['topic_default_rem'] !== null ? (int)$r['topic_default_rem'] : null
);
$r['_status'] = $s;
$counters['all']++;
$counters[$s['code']] = ($counters[$s['code']] ?? 0) + 1;
if ($fStatus !== '' && $fStatus !== $s['code']) continue;
$filtered[] = $r;
}
/* Righe coperte: stesso calcolo di stato delle righe reali */
foreach ($virtualRows as $v) {
$s = trainingStatus(
$v['next_due_date'] ?: null,
null,
$v['topic_default_rem'] !== null ? (int)$v['topic_default_rem'] : null
);
$v['_status'] = $s;
$counters['all']++;
$counters[$s['code']] = ($counters[$s['code']] ?? 0) + 1;
if ($fStatus !== '' && $fStatus !== $s['code']) continue;
$filtered[] = $v;
}
/* ==========================================
"NOT PRESENT" — mandatory topics without any record for an employee.
Apply the same filters (employee_id / topic_id / department_id / type=initial).
========================================== */
if ($fType === '' || $fType === 'initial') {
$missingWhere = [];
$missingParams = [];
if ($fEmployeeId > 0) {
$missingWhere[] = 'e.id = :eid';
$missingParams['eid'] = $fEmployeeId;
}
if ($fTopicId > 0) {
$missingWhere[] = 'tt.id = :tid';
$missingParams['tid'] = $fTopicId;
}
if ($fDepartmentId > 0) {
$missingWhere[] = 'e.department_id = :did';
$missingParams['did'] = $fDepartmentId;
}
$missingWhereSql = $missingWhere ? ('AND ' . implode(' AND ', $missingWhere)) : '';
$missingStmt = $pdo->prepare("
SELECT e.id AS employee_id, e.first_name, e.last_name, e.employee_code,
d.name AS department_name, d.color AS department_color,
tt.id AS topic_id, tt.name AS topic_name
FROM employees e
CROSS JOIN training_topics tt
LEFT JOIN departments d ON d.id = e.department_id
WHERE tt.is_active = 1 AND tt.is_mandatory = 1
AND NOT EXISTS (
SELECT 1 FROM employee_trainings et
WHERE et.employee_id = e.id AND et.training_topic_id = tt.id
)
$missingWhereSql
ORDER BY e.last_name, e.first_name, tt.name
");
$missingStmt->execute($missingParams);
$missingRows = $missingStmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($missingRows as $m) {
// Se il corso obbligatorio è assorbito da un altro già svolto, non è "Non presente"
if (isset($coveredKeys[$m['employee_id'] . ':' . $m['topic_id']])) continue;
$counters['all']++;
$counters['not_present']++;
if ($fStatus !== '' && $fStatus !== 'not_present') continue;
$filtered[] = [
'id' => null,
'_virtual' => true,
'employee_id' => $m['employee_id'],
'first_name' => $m['first_name'],
'last_name' => $m['last_name'],
'employee_code' => $m['employee_code'],
'department_name' => $m['department_name'],
'department_color' => $m['department_color'],
'training_topic_id' => $m['topic_id'],
'topic_name' => $m['topic_name'],
'training_type' => null,
'completed_date' => null,
'next_due_date' => null,
'attachments_count' => 0,
'_status' => ['code' => 'not_present', 'label' => 'Non presente', 'class' => 'secondary', 'days' => null],
];
}
}
/* Dropdown data */
$employees = $pdo->query("
SELECT id, first_name, last_name, employee_code, department_id
FROM employees
ORDER BY last_name, first_name
")->fetchAll(PDO::FETCH_ASSOC);
$topics = $pdo->query("
SELECT id, name, default_frequency_months, default_reminder_days
FROM training_topics WHERE is_active = 1 ORDER BY sort_order, name
")->fetchAll(PDO::FETCH_ASSOC);
$departments = $pdo->query("
SELECT id, name, color FROM departments WHERE is_active = 1 ORDER BY sort_order, name
")->fetchAll(PDO::FETCH_ASSOC);
function fmtDate(?string $d): string
{
if (!$d || $d === '0000-00-00') return '—';
$ts = strtotime($d);
return $ts ? date('d/m/Y', $ts) : '—';
}
?>
Storico Formazione - = htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?>
0 selezionati
Nessuna formazione corrispondente ai filtri.
= $r['_status']['label'] ?>
📖 = htmlspecialchars($r['topic_name']) ?>
⛓️ Coperto da = htmlspecialchars($r['_covered_by_name']) ?>
Tipo: = $typeLbl ?>
Completato: = fmtDate($r['completed_date']) ?>
Prossimo: = fmtDate($r['next_due_date']) ?>
(= $days ?>g)
(+= $days ?>g)
Reparto: = htmlspecialchars($r['department_name']) ?>