added assorbimento corso

This commit is contained in:
2026-07-24 08:32:50 +02:00
parent 6d60f89be2
commit c11adf19e8
6 changed files with 691 additions and 7 deletions
+111 -3
View File
@@ -74,6 +74,15 @@ $stmt = $pdo->prepare("
$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
{
@@ -90,6 +99,60 @@ function trainingStatus(?string $nextDue, ?int $reminderDays, ?int $topicDefault
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) {
@@ -106,6 +169,21 @@ foreach ($rows as $r) {
$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).
@@ -146,6 +224,9 @@ if ($fType === '' || $fType === 'initial') {
$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;
@@ -421,6 +502,19 @@ function fmtDate(?string $d): string
border: 1px solid #cbd5e1;
}
.pill-covered {
background: #ede9fe;
color: #5b21b6;
border: 1px solid #ddd6fe;
font-size: .78rem;
margin-left: 6px;
white-space: nowrap;
}
tr.row-covered {
background-color: #faf5ff !important;
}
.pill-dept-inline {
padding: 2px 8px;
}
@@ -617,7 +711,7 @@ function fmtDate(?string $d): string
$typeLbl = $r['training_type'] === 'refresher' ? 'Aggiornamento' : ($r['training_type'] === 'initial' ? 'Iniziale' : '—');
$days = $r['_status']['days'] ?? null;
?>
<tr>
<tr class="<?= !empty($r['_covered']) ? 'row-covered' : '' ?>">
<td>
<?php if (!empty($r['id'])): ?>
<input type="checkbox" class="form-check-input row-check" value="<?= (int)$r['id'] ?>">
@@ -638,7 +732,14 @@ function fmtDate(?string $d): string
</span>
<?php else: ?>—<?php endif; ?>
</td>
<td><?= htmlspecialchars($r['topic_name']) ?></td>
<td>
<?= htmlspecialchars($r['topic_name']) ?>
<?php if (!empty($r['_covered'])): ?>
<span class="pill pill-covered" title="Assorbito automaticamente dal corso <?= htmlspecialchars($r['_covered_by_name'], ENT_QUOTES) ?>">
⛓️ Coperto da <?= htmlspecialchars($r['_covered_by_name']) ?>
</span>
<?php endif; ?>
</td>
<td><span class="pill pill-role"><?= $typeLbl ?></span></td>
<td><?= fmtDate($r['completed_date']) ?></td>
<td><?= fmtDate($r['next_due_date']) ?></td>
@@ -677,7 +778,14 @@ function fmtDate(?string $d): string
</div>
<span class="pill pill-<?= $r['_status']['class'] ?>"><?= $r['_status']['label'] ?></span>
</div>
<div class="topic">📖 <?= htmlspecialchars($r['topic_name']) ?></div>
<div class="topic">
📖 <?= htmlspecialchars($r['topic_name']) ?>
<?php if (!empty($r['_covered'])): ?>
<div class="mt-1">
<span class="pill pill-covered">⛓️ Coperto da <?= htmlspecialchars($r['_covered_by_name']) ?></span>
</div>
<?php endif; ?>
</div>
<div class="meta">
<span><b>Tipo:</b> <?= $typeLbl ?></span>
<span><b>Completato:</b> <?= fmtDate($r['completed_date']) ?></span>