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
+66 -2
View File
@@ -1,6 +1,7 @@
<?php
require_once(__DIR__ . '/../auth_check.php');
require_once(__DIR__ . '/../../class/db-functions.php');
require_once(__DIR__ . '/../../include/training_coverage.php');
header('Content-Type: application/json');
@@ -24,16 +25,58 @@ $is_mandatory = isset($_POST['is_mandatory']) && (int)$_POST['is_mandatory'] ===
$freq = ($freqRaw === '' || $freqRaw === null) ? null : max(0, (int)$freqRaw);
$rem = ($remRaw === '' || $remRaw === null) ? 30 : max(0, (int)$remRaw);
/* Coperture: corsi assorbiti da questo */
$coveredIds = isset($_POST['covered_ids']) ? (array)$_POST['covered_ids'] : [];
$coveredIds = array_values(array_unique(array_filter(array_map('intval', $coveredIds), function ($v) {
return $v > 0;
})));
if ($name === '') {
echo json_encode(['success' => false, 'message' => 'Il nome del corso è obbligatorio.']);
exit;
}
/**
* Riscrive le coperture del corso: cancella le vecchie e inserisce le nuove,
* scartando auto-copertura e relazioni che creerebbero un ciclo.
*/
function saveCoverage(PDO $pdo, int $topicId, array $coveredIds): array
{
$skipped = [];
$pdo->prepare("DELETE FROM training_topic_coverage WHERE topic_id = :tid")
->execute(['tid' => $topicId]);
if (!$coveredIds) return $skipped;
$ins = $pdo->prepare("
INSERT IGNORE INTO training_topic_coverage
(topic_id, covered_topic_id, inherit_expiry, created_at)
VALUES (:tid, :cid, 'own', NOW())
");
foreach ($coveredIds as $cid) {
if ($cid === $topicId) {
continue;
}
if (tc_wouldCreateCycle($pdo, $topicId, $cid)) {
$skipped[] = $cid;
continue;
}
$ins->execute(['tid' => $topicId, 'cid' => $cid]);
}
return $skipped;
}
try {
$pdo->beginTransaction();
if ($id > 0) {
$check = $pdo->prepare("SELECT COUNT(*) FROM training_topics WHERE name = :name AND id <> :id");
$check->execute(['name' => $name, 'id' => $id]);
if ((int)$check->fetchColumn() > 0) {
$pdo->rollBack();
echo json_encode(['success' => false, 'message' => 'Esiste già un altro corso con questo nome.']);
exit;
}
@@ -61,13 +104,27 @@ try {
'id' => $id,
]);
echo json_encode(['success' => true, 'id' => $id]);
$skipped = saveCoverage($pdo, $id, $coveredIds);
$pdo->commit();
$msg = $skipped
? 'Corso aggiornato. Alcune coperture sono state ignorate perché avrebbero creato un riferimento circolare.'
: null;
echo json_encode(array_filter([
'success' => true,
'id' => $id,
'message' => $msg,
], function ($v) {
return $v !== null;
}));
exit;
}
$check = $pdo->prepare("SELECT COUNT(*) FROM training_topics WHERE name = :name");
$check->execute(['name' => $name]);
if ((int)$check->fetchColumn() > 0) {
$pdo->rollBack();
echo json_encode(['success' => false, 'message' => 'Esiste già un corso con questo nome.']);
exit;
}
@@ -88,7 +145,14 @@ try {
'is_mandatory' => $is_mandatory,
]);
echo json_encode(['success' => true, 'id' => (int)$pdo->lastInsertId()]);
$newId = (int)$pdo->lastInsertId();
saveCoverage($pdo, $newId, $coveredIds);
$pdo->commit();
echo json_encode(['success' => true, 'id' => $newId]);
} catch (Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+104
View File
@@ -162,6 +162,8 @@ if ($employee) {
$trainings = [];
$trainingTopicsAll = [];
$missingMandatoryTopics = [];
$coveredTrainings = [];
$coveredKeys = [];
if ($employee) {
$stmt = $pdo->prepare("
SELECT et.*,
@@ -207,6 +209,34 @@ if ($employee) {
");
$missingStmt->execute(['eid' => $employeeId]);
$missingMandatoryTopics = $missingStmt->fetchAll(PDO::FETCH_ASSOC);
/* ==========================================
COPERTURE corsi assorbiti da altri corsi
========================================== */
require_once __DIR__ . '/include/training_coverage.php';
$allTopicsById = [];
foreach ($pdo->query("SELECT id, name, default_frequency_months, default_reminder_days, is_active FROM training_topics")->fetchAll(PDO::FETCH_ASSOC) as $tt) {
if ((int)$tt['is_active'] !== 1) continue;
$allTopicsById[(int)$tt['id']] = $tt;
}
// Solo i record più recenti per topic generano copertura
$latestOnly = array_values(array_filter($trainings, function ($t) {
return !empty($t['_is_latest']);
}));
foreach ($latestOnly as &$lo) {
$lo['employee_id'] = $employeeId;
}
unset($lo);
$coveredTrainings = tc_buildVirtualRows($pdo, $latestOnly, $allTopicsById);
$coveredKeys = tc_coveredKeys($coveredTrainings);
// I corsi obbligatori coperti non sono più "non presenti"
$missingMandatoryTopics = array_values(array_filter($missingMandatoryTopics, function ($mt) use ($coveredKeys, $employeeId) {
return !isset($coveredKeys[$employeeId . ':' . (int)$mt['id']]);
}));
}
/* ==========================================
@@ -590,6 +620,29 @@ function fmtFileSize(?int $bytes): string
border: 1px solid #cbd5e1;
}
.pill-covered {
background: #ede9fe;
color: #5b21b6;
border: 1px solid #ddd6fe;
font-size: .78rem;
padding: 3px 9px;
white-space: nowrap;
}
tr.row-covered {
background-color: #faf5ff !important;
}
tr.row-covered td {
background-color: transparent !important;
}
.cov-note {
font-size: .8rem;
color: #7c3aed;
margin-top: 2px;
}
.pill-status-success {
background: #d1fae5;
color: #065f46;
@@ -1641,12 +1694,57 @@ function fmtFileSize(?int $bytes): string
</td>
</tr>
<?php endforeach; ?>
<?php foreach ($coveredTrainings as $cv): ?>
<?php
$s = trainingStatus(
$cv['next_due_date'] ?: null,
null,
$cv['topic_default_rem'] !== null ? (int)$cv['topic_default_rem'] : null
);
?>
<tr class="row-covered">
<td class="fw-semibold">
<?= htmlspecialchars($cv['topic_name']) ?>
<div class="cov-note">⛓️ Assorbito da <?= htmlspecialchars($cv['_covered_by_name']) ?></div>
</td>
<td><span class="pill pill-covered">Coperto</span></td>
<td><?= fmtDate($cv['completed_date']) ?></td>
<td><?= fmtDate($cv['next_due_date']) ?></td>
<td><span class="pill pill-status-<?= $s['class'] ?>"><?= $s['label'] ?></span></td>
<td></td>
<td class="text-end"><span class="text-muted small"></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- MOBILE CARDS -->
<div class="d-block d-md-none">
<?php foreach ($coveredTrainings as $cv): ?>
<?php
$s = trainingStatus(
$cv['next_due_date'] ?: null,
null,
$cv['topic_default_rem'] !== null ? (int)$cv['topic_default_rem'] : null
);
?>
<div class="doc-card" style="background:#faf5ff;">
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
<span class="doc-card-title">📖 <?= htmlspecialchars($cv['topic_name']) ?></span>
<span class="pill pill-status-<?= $s['class'] ?>"><?= $s['label'] ?></span>
</div>
<div class="mb-2"><span class="pill pill-covered">⛓️ Assorbito da <?= htmlspecialchars($cv['_covered_by_name']) ?></span></div>
<div class="doc-card-meta">
<span><b>Completato:</b> <?= fmtDate($cv['completed_date']) ?></span>
<?php if ($cv['next_due_date']): ?>
<span><b>Prossimo:</b> <?= fmtDate($cv['next_due_date']) ?></span>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php foreach ($trainings as $t): ?>
<?php
$tid = (int)$t['id'];
@@ -2305,6 +2403,12 @@ function fmtFileSize(?int $bytes): string
order: [
[2, 'desc']
],
createdRow: function(row, data, index) {
// preserva l'evidenziazione delle righe coperte dopo il redraw
if ($(row).find('.pill-covered').length) {
$(row).addClass('row-covered');
}
},
language: {
search: "Cerca:",
lengthMenu: "Mostra _MENU_ righe",
@@ -0,0 +1,199 @@
<?php
/**
* Helper per la gestione delle coperture tra corsi di formazione.
* Un corso "madre" (topic_id) assorbe uno o più corsi "coperti" (covered_topic_id).
*/
/**
* Mappa completa delle coperture: [topic_id => [covered_topic_id => inherit_expiry]]
*/
function tc_getCoverageMap(PDO $pdo): array
{
static $cache = null;
if ($cache !== null) return $cache;
$rows = $pdo->query("
SELECT topic_id, covered_topic_id, inherit_expiry
FROM training_topic_coverage
")->fetchAll(PDO::FETCH_ASSOC);
$map = [];
foreach ($rows as $r) {
$map[(int)$r['topic_id']][(int)$r['covered_topic_id']] = $r['inherit_expiry'];
}
return $cache = $map;
}
/**
* Mappa inversa: [covered_topic_id => [topic_id, ...]]
*/
function tc_getReverseCoverageMap(PDO $pdo): array
{
static $cache = null;
if ($cache !== null) return $cache;
$map = [];
foreach (tc_getCoverageMap($pdo) as $topicId => $covered) {
foreach (array_keys($covered) as $coveredId) {
$map[$coveredId][] = $topicId;
}
}
return $cache = $map;
}
/**
* Elenco dei corsi coperti da un dato corso, con i nomi.
* @return array[] [['id'=>int,'name'=>string,'inherit_expiry'=>string], ...]
*/
function tc_getCoveredTopics(PDO $pdo, int $topicId): array
{
$st = $pdo->prepare("
SELECT tt.id, tt.name, c.inherit_expiry
FROM training_topic_coverage c
JOIN training_topics tt ON tt.id = c.covered_topic_id
WHERE c.topic_id = :tid
ORDER BY tt.sort_order, tt.name
");
$st->execute(['tid' => $topicId]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Calcola la data di scadenza per un corso coperto.
*
* @param string $completedDate data completamento del corso madre (Y-m-d)
* @param string $inherit 'own' | 'source' | 'none'
* @param int|null $coveredFreq frequenza (mesi) del corso coperto
* @param string|null $sourceNextDue next_due_date del corso madre
*/
function tc_computeDueDate(
string $completedDate,
string $inherit,
?int $coveredFreq,
?string $sourceNextDue
): ?string {
if ($inherit === 'none') return null;
if ($inherit === 'source') return $sourceNextDue ?: null;
// 'own'
if ($coveredFreq === null || $coveredFreq <= 0) return null;
$d = DateTime::createFromFormat('Y-m-d', $completedDate);
if (!$d) return null;
$d->modify('+' . $coveredFreq . ' months');
return $d->format('Y-m-d');
}
/**
* Dato l'elenco dei training reali di uno o più dipendenti, produce le
* righe "virtuali" derivate dalle coperture.
*
* @param array $realRows righe da employee_trainings (servono almeno:
* employee_id, training_topic_id, completed_date,
* next_due_date, e i dati anagrafici del dipendente)
* @param array $topicsById [id => ['name'=>..,'default_frequency_months'=>..,'default_reminder_days'=>..]]
* @return array righe virtuali pronte per il rendering
*/
function tc_buildVirtualRows(PDO $pdo, array $realRows, array $topicsById): array
{
$coverage = tc_getCoverageMap($pdo);
if (!$coverage) return [];
// Indice dei record diretti già esistenti: "empId:topicId"
$direct = [];
foreach ($realRows as $r) {
$direct[$r['employee_id'] . ':' . $r['training_topic_id']] = true;
}
$virtual = [];
foreach ($realRows as $r) {
$srcTopicId = (int)$r['training_topic_id'];
if (empty($coverage[$srcTopicId])) continue;
foreach ($coverage[$srcTopicId] as $coveredId => $inherit) {
$key = $r['employee_id'] . ':' . $coveredId;
// Precedenza al record diretto
if (isset($direct[$key])) continue;
// Il corso coperto deve esistere ed essere attivo
if (!isset($topicsById[$coveredId])) continue;
// Evita doppioni se più corsi madre coprono lo stesso corso:
// tiene quello con completed_date più recente
if (isset($virtual[$key]) && $virtual[$key]['completed_date'] >= $r['completed_date']) continue;
$covFreq = $topicsById[$coveredId]['default_frequency_months'];
$covFreq = ($covFreq === null || $covFreq === '') ? null : (int)$covFreq;
$due = tc_computeDueDate(
(string)$r['completed_date'],
(string)$inherit,
$covFreq,
$r['next_due_date'] ?: null
);
$virtual[$key] = [
'id' => null,
'_covered' => true,
'_covered_by_id' => $srcTopicId,
'_covered_by_name' => $topicsById[$srcTopicId]['name'] ?? ('#' . $srcTopicId),
'employee_id' => $r['employee_id'],
'first_name' => $r['first_name'] ?? '',
'last_name' => $r['last_name'] ?? '',
'employee_code' => $r['employee_code'] ?? null,
'department_name' => $r['department_name'] ?? null,
'department_color' => $r['department_color'] ?? null,
'training_topic_id' => $coveredId,
'topic_name' => $topicsById[$coveredId]['name'],
'topic_default_rem' => $topicsById[$coveredId]['default_reminder_days'] ?? null,
'training_type' => $r['training_type'] ?? 'initial',
'completed_date' => $r['completed_date'],
'next_due_date' => $due,
'reminder_days' => null,
'attachments_count' => 0,
];
}
}
return array_values($virtual);
}
/**
* Insieme di chiavi "empId:topicId" coperte, usato per escludere
* i corsi obbligatori dai "Non presenti".
*/
function tc_coveredKeys(array $virtualRows): array
{
$keys = [];
foreach ($virtualRows as $v) {
$keys[$v['employee_id'] . ':' . $v['training_topic_id']] = true;
}
return $keys;
}
/**
* Verifica se aggiungere topicId -> coveredId creerebbe un ciclo.
*/
function tc_wouldCreateCycle(PDO $pdo, int $topicId, int $coveredId): bool
{
if ($topicId === $coveredId) return true;
$map = [];
$rows = $pdo->query("SELECT topic_id, covered_topic_id FROM training_topic_coverage")
->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$map[(int)$r['topic_id']][] = (int)$r['covered_topic_id'];
}
$map[$topicId][] = $coveredId;
// DFS da coveredId: se raggiungo topicId c'è ciclo
$stack = [$coveredId];
$visited = [];
while ($stack) {
$cur = array_pop($stack);
if ($cur === $topicId) return true;
if (isset($visited[$cur])) continue;
$visited[$cur] = true;
foreach ($map[$cur] ?? [] as $next) $stack[] = $next;
}
return false;
}
+150 -2
View File
@@ -7,6 +7,8 @@ $pdo = $db->getConnection();
/* ==========================================
PAGE DATA
========================================== */
require_once __DIR__ . '/include/training_coverage.php';
$sql = "
SELECT tt.*,
(SELECT COUNT(*) FROM employee_trainings et WHERE et.training_topic_id = tt.id) AS trainings_count
@@ -14,6 +16,30 @@ $sql = "
ORDER BY tt.sort_order ASC, tt.name ASC
";
$topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
/* Coperture: [topic_id => [ ['id'=>..,'name'=>..], ... ]] */
$coverageRows = $pdo->query("
SELECT c.topic_id, c.covered_topic_id, tt.name AS covered_name
FROM training_topic_coverage c
JOIN training_topics tt ON tt.id = c.covered_topic_id
ORDER BY tt.sort_order, tt.name
")->fetchAll(PDO::FETCH_ASSOC);
$coverageByTopic = [];
foreach ($coverageRows as $cr) {
$coverageByTopic[(int)$cr['topic_id']][] = [
'id' => (int)$cr['covered_topic_id'],
'name' => $cr['covered_name'],
];
}
/* Mappa inversa: chi copre questo corso */
$coveredByTopic = [];
foreach ($coverageRows as $cr) {
$coveredByTopic[(int)$cr['covered_topic_id']][] = (int)$cr['topic_id'];
}
$topicNames = [];
foreach ($topics as $t) $topicNames[(int)$t['id']] = $t['name'];
?>
<!doctype html>
<html lang="it">
@@ -274,6 +300,42 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
color: #94a3b8;
padding: 24px 0;
}
.cov-chip {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
background: #ede9fe;
color: #5b21b6;
border: 1px solid #ddd6fe;
font-size: 0.78rem;
font-weight: 600;
margin: 2px 3px 2px 0;
white-space: nowrap;
}
.cov-chip-parent {
background: #fef3c7;
color: #92400e;
border-color: #fde68a;
}
.cov-cell {
max-width: 240px;
text-align: left;
white-space: normal;
}
.cov-help {
font-size: .85rem;
color: #64748b;
margin-top: 4px;
}
#addCovered,
#editCovered {
min-height: 140px;
}
</style>
</head>
@@ -328,6 +390,7 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
<th>Ordine</th>
<th>Stato</th>
<th>Formazioni</th>
<th>Copre</th>
<th>Azioni</th>
</tr>
</thead>
@@ -368,6 +431,25 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
<td><?= $sortOrder ?></td>
<td><span class="badge-status <?= $statusClass ?>"><?= $statusLabel ?></span></td>
<td><?= $cnt ?></td>
<td class="cov-cell">
<?php $covList = $coverageByTopic[$id] ?? []; ?>
<?php if ($covList): ?>
<?php foreach ($covList as $cv): ?>
<span class="cov-chip">⛓️ <?= htmlspecialchars($cv['name']) ?></span>
<?php endforeach; ?>
<?php else: ?>
<span class="text-muted"></span>
<?php endif; ?>
<?php if (!empty($coveredByTopic[$id])): ?>
<div class="mt-1">
<?php foreach ($coveredByTopic[$id] as $parentId): ?>
<span class="cov-chip cov-chip-parent" title="Questo corso è assorbito da">
<?= htmlspecialchars($topicNames[$parentId] ?? ('#' . $parentId)) ?>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</td>
<td>
<button class="btn btn-sm btn-outline-secondary edit-topic"
data-id="<?= $id ?>"
@@ -377,7 +459,8 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
data-rem="<?= $rem ?>"
data-sort_order="<?= $sortOrder ?>"
data-is_active="<?= $isActive ?>"
data-is_mandatory="<?= $isMandatory ?>">
data-is_mandatory="<?= $isMandatory ?>"
data-covered="<?= htmlspecialchars(json_encode(array_column($coverageByTopic[$id] ?? [], 'id')), ENT_QUOTES) ?>">
✏️ Modifica
</button>
<button class="btn btn-sm btn-outline-danger delete-topic"
@@ -430,6 +513,17 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
<span><b>Formazioni:</b> <?= $cnt ?></span>
<span><b>Ordine:</b> <?= $sortOrder ?></span>
</div>
<?php $covList = $coverageByTopic[$id] ?? []; ?>
<?php if ($covList || !empty($coveredByTopic[$id])): ?>
<div class="mb-2">
<?php foreach ($covList as $cv): ?>
<span class="cov-chip">⛓️ <?= htmlspecialchars($cv['name']) ?></span>
<?php endforeach; ?>
<?php foreach (($coveredByTopic[$id] ?? []) as $parentId): ?>
<span class="cov-chip cov-chip-parent"> <?= htmlspecialchars($topicNames[$parentId] ?? ('#' . $parentId)) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="tt-card-actions">
<button class="btn btn-sm btn-outline-secondary edit-topic"
data-id="<?= $id ?>"
@@ -439,7 +533,8 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
data-rem="<?= $rem ?>"
data-sort_order="<?= $sortOrder ?>"
data-is_active="<?= $isActive ?>"
data-is_mandatory="<?= $isMandatory ?>">
data-is_mandatory="<?= $isMandatory ?>"
data-covered="<?= htmlspecialchars(json_encode(array_column($coverageByTopic[$id] ?? [], 'id')), ENT_QUOTES) ?>">
✏️ Modifica
</button>
<button class="btn btn-sm btn-outline-danger delete-topic"
@@ -520,6 +615,19 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
Se attivo, i dipendenti senza registrazione di questo corso compaiono come "Non presente" nello storico.
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">⛓️ Corsi assorbiti da questo</label>
<select class="form-select" id="addCovered" multiple size="6">
<?php foreach ($topics as $t): ?>
<option value="<?= (int)$t['id'] ?>"><?= htmlspecialchars($t['name']) ?></option>
<?php endforeach; ?>
</select>
<div class="cov-help">
Chi completa questo corso risulta automaticamente in regola anche per quelli selezionati.
La scadenza del corso assorbito viene ricalcolata con la sua frequenza propria.
</div>
</div>
<div class="text-center">
<button type="submit" class="btn btn-add">💾 Salva</button>
</div>
@@ -590,6 +698,18 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
Se attivo, i dipendenti senza registrazione di questo corso compaiono come "Non presente" nello storico.
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">⛓️ Corsi assorbiti da questo</label>
<select class="form-select" id="editCovered" multiple size="6">
<?php foreach ($topics as $t): ?>
<option value="<?= (int)$t['id'] ?>"><?= htmlspecialchars($t['name']) ?></option>
<?php endforeach; ?>
</select>
<div class="cov-help">
Chi completa questo corso risulta automaticamente in regola anche per quelli selezionati.
</div>
</div>
<div class="text-center">
<button type="submit" class="btn btn-add">💾 Salva Modifiche</button>
</div>
@@ -604,6 +724,10 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
<script>
$(document).ready(function() {
$('#tabellaTopics').DataTable({
columnDefs: [{
targets: [8, 9],
orderable: false
}],
order: [
[5, 'asc'],
[1, 'asc']
@@ -660,6 +784,9 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
p.append('sort_order', $("#addSortOrder").val());
p.append('is_active', $("#addIsActive").val());
p.append('is_mandatory', $("#addIsMandatory").is(':checked') ? '1' : '0');
($("#addCovered").val() || []).forEach(function(v) {
p.append('covered_ids[]', v);
});
ajaxPost("ajax/training_topics/save.php", p, "Salvato!", "Impossibile salvare il corso.");
});
@@ -678,6 +805,24 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
$("#editSortOrder").val(b.data("sort_order"));
$("#editIsActive").val(String(b.data("is_active")));
$("#editIsMandatory").prop('checked', String(b.data("is_mandatory")) === '1');
// Coperture: nascondi il corso stesso e preseleziona le esistenti
var selfId = String(b.data("id"));
var covered = b.data("covered") || [];
if (typeof covered === 'string') {
try {
covered = JSON.parse(covered);
} catch (e) {
covered = [];
}
}
covered = covered.map(String);
$("#editCovered option").each(function() {
$(this).prop('hidden', this.value === selfId)
.prop('disabled', this.value === selfId);
});
$("#editCovered").val(covered);
$("#editTopicModal").modal("show");
});
@@ -692,6 +837,9 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
p.append('sort_order', $("#editSortOrder").val());
p.append('is_active', $("#editIsActive").val());
p.append('is_mandatory', $("#editIsMandatory").is(':checked') ? '1' : '0');
($("#editCovered").val() || []).forEach(function(v) {
p.append('covered_ids[]', v);
});
ajaxPost("ajax/training_topics/save.php", p, "Aggiornato!", "Impossibile aggiornare il corso.");
});
+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>