added assorbimento corso
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
final class CreateTrainingTopicCoverage extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function change(): void
|
||||||
|
{
|
||||||
|
$table = $this->table('training_topic_coverage', [
|
||||||
|
'id' => 'id',
|
||||||
|
'signed' => false,
|
||||||
|
'engine' => 'InnoDB',
|
||||||
|
'encoding' => 'utf8mb4',
|
||||||
|
'collation' => 'utf8mb4_unicode_ci',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$table
|
||||||
|
->addColumn('topic_id', 'integer', [
|
||||||
|
'signed' => false,
|
||||||
|
'null' => false,
|
||||||
|
'comment' => 'Corso madre (es. RSPP)',
|
||||||
|
])
|
||||||
|
->addColumn('covered_topic_id', 'integer', [
|
||||||
|
'signed' => false,
|
||||||
|
'null' => false,
|
||||||
|
'comment' => 'Corso assorbito (es. Antincendio)',
|
||||||
|
])
|
||||||
|
->addColumn('inherit_expiry', 'enum', [
|
||||||
|
'values' => ['own', 'source', 'none'],
|
||||||
|
'default' => 'own',
|
||||||
|
'null' => false,
|
||||||
|
'comment' => 'own = ricalcola con frequenza propria del corso coperto',
|
||||||
|
])
|
||||||
|
->addColumn('notes', 'string', [
|
||||||
|
'limit' => 255,
|
||||||
|
'null' => true,
|
||||||
|
])
|
||||||
|
->addColumn('created_at', 'timestamp', [
|
||||||
|
'default' => 'CURRENT_TIMESTAMP',
|
||||||
|
'null' => true,
|
||||||
|
])
|
||||||
|
->addIndex(['topic_id', 'covered_topic_id'], [
|
||||||
|
'unique' => true,
|
||||||
|
'name' => 'uq_topic_coverage',
|
||||||
|
])
|
||||||
|
->addIndex(['covered_topic_id'], ['name' => 'idx_covered_topic'])
|
||||||
|
->addForeignKey('topic_id', 'training_topics', 'id', [
|
||||||
|
'delete' => 'CASCADE',
|
||||||
|
'update' => 'CASCADE',
|
||||||
|
'constraint' => 'fk_cov_topic',
|
||||||
|
])
|
||||||
|
->addForeignKey('covered_topic_id', 'training_topics', 'id', [
|
||||||
|
'delete' => 'CASCADE',
|
||||||
|
'update' => 'CASCADE',
|
||||||
|
'constraint' => 'fk_cov_covered',
|
||||||
|
])
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once(__DIR__ . '/../auth_check.php');
|
require_once(__DIR__ . '/../auth_check.php');
|
||||||
require_once(__DIR__ . '/../../class/db-functions.php');
|
require_once(__DIR__ . '/../../class/db-functions.php');
|
||||||
|
require_once(__DIR__ . '/../../include/training_coverage.php');
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
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);
|
$freq = ($freqRaw === '' || $freqRaw === null) ? null : max(0, (int)$freqRaw);
|
||||||
$rem = ($remRaw === '' || $remRaw === null) ? 30 : max(0, (int)$remRaw);
|
$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 === '') {
|
if ($name === '') {
|
||||||
echo json_encode(['success' => false, 'message' => 'Il nome del corso è obbligatorio.']);
|
echo json_encode(['success' => false, 'message' => 'Il nome del corso è obbligatorio.']);
|
||||||
exit;
|
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 {
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
$check = $pdo->prepare("SELECT COUNT(*) FROM training_topics WHERE name = :name AND id <> :id");
|
$check = $pdo->prepare("SELECT COUNT(*) FROM training_topics WHERE name = :name AND id <> :id");
|
||||||
$check->execute(['name' => $name, 'id' => $id]);
|
$check->execute(['name' => $name, 'id' => $id]);
|
||||||
if ((int)$check->fetchColumn() > 0) {
|
if ((int)$check->fetchColumn() > 0) {
|
||||||
|
$pdo->rollBack();
|
||||||
echo json_encode(['success' => false, 'message' => 'Esiste già un altro corso con questo nome.']);
|
echo json_encode(['success' => false, 'message' => 'Esiste già un altro corso con questo nome.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -61,13 +104,27 @@ try {
|
|||||||
'id' => $id,
|
'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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$check = $pdo->prepare("SELECT COUNT(*) FROM training_topics WHERE name = :name");
|
$check = $pdo->prepare("SELECT COUNT(*) FROM training_topics WHERE name = :name");
|
||||||
$check->execute(['name' => $name]);
|
$check->execute(['name' => $name]);
|
||||||
if ((int)$check->fetchColumn() > 0) {
|
if ((int)$check->fetchColumn() > 0) {
|
||||||
|
$pdo->rollBack();
|
||||||
echo json_encode(['success' => false, 'message' => 'Esiste già un corso con questo nome.']);
|
echo json_encode(['success' => false, 'message' => 'Esiste già un corso con questo nome.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -88,7 +145,14 @@ try {
|
|||||||
'is_mandatory' => $is_mandatory,
|
'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) {
|
} catch (Exception $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ if ($employee) {
|
|||||||
$trainings = [];
|
$trainings = [];
|
||||||
$trainingTopicsAll = [];
|
$trainingTopicsAll = [];
|
||||||
$missingMandatoryTopics = [];
|
$missingMandatoryTopics = [];
|
||||||
|
$coveredTrainings = [];
|
||||||
|
$coveredKeys = [];
|
||||||
if ($employee) {
|
if ($employee) {
|
||||||
$stmt = $pdo->prepare("
|
$stmt = $pdo->prepare("
|
||||||
SELECT et.*,
|
SELECT et.*,
|
||||||
@@ -207,6 +209,34 @@ if ($employee) {
|
|||||||
");
|
");
|
||||||
$missingStmt->execute(['eid' => $employeeId]);
|
$missingStmt->execute(['eid' => $employeeId]);
|
||||||
$missingMandatoryTopics = $missingStmt->fetchAll(PDO::FETCH_ASSOC);
|
$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;
|
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 {
|
.pill-status-success {
|
||||||
background: #d1fae5;
|
background: #d1fae5;
|
||||||
color: #065f46;
|
color: #065f46;
|
||||||
@@ -1641,12 +1694,57 @@ function fmtFileSize(?int $bytes): string
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MOBILE CARDS -->
|
<!-- MOBILE CARDS -->
|
||||||
<div class="d-block d-md-none">
|
<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 foreach ($trainings as $t): ?>
|
||||||
<?php
|
<?php
|
||||||
$tid = (int)$t['id'];
|
$tid = (int)$t['id'];
|
||||||
@@ -2305,6 +2403,12 @@ function fmtFileSize(?int $bytes): string
|
|||||||
order: [
|
order: [
|
||||||
[2, 'desc']
|
[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: {
|
language: {
|
||||||
search: "Cerca:",
|
search: "Cerca:",
|
||||||
lengthMenu: "Mostra _MENU_ righe",
|
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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ $pdo = $db->getConnection();
|
|||||||
/* ==========================================
|
/* ==========================================
|
||||||
PAGE DATA
|
PAGE DATA
|
||||||
========================================== */
|
========================================== */
|
||||||
|
require_once __DIR__ . '/include/training_coverage.php';
|
||||||
|
|
||||||
$sql = "
|
$sql = "
|
||||||
SELECT tt.*,
|
SELECT tt.*,
|
||||||
(SELECT COUNT(*) FROM employee_trainings et WHERE et.training_topic_id = tt.id) AS trainings_count
|
(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
|
ORDER BY tt.sort_order ASC, tt.name ASC
|
||||||
";
|
";
|
||||||
$topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
$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>
|
<!doctype html>
|
||||||
<html lang="it">
|
<html lang="it">
|
||||||
@@ -274,6 +300,42 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
padding: 24px 0;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -328,6 +390,7 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
<th>Ordine</th>
|
<th>Ordine</th>
|
||||||
<th>Stato</th>
|
<th>Stato</th>
|
||||||
<th>Formazioni</th>
|
<th>Formazioni</th>
|
||||||
|
<th>Copre</th>
|
||||||
<th>Azioni</th>
|
<th>Azioni</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -368,6 +431,25 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
<td><?= $sortOrder ?></td>
|
<td><?= $sortOrder ?></td>
|
||||||
<td><span class="badge-status <?= $statusClass ?>"><?= $statusLabel ?></span></td>
|
<td><span class="badge-status <?= $statusClass ?>"><?= $statusLabel ?></span></td>
|
||||||
<td><?= $cnt ?></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>
|
<td>
|
||||||
<button class="btn btn-sm btn-outline-secondary edit-topic"
|
<button class="btn btn-sm btn-outline-secondary edit-topic"
|
||||||
data-id="<?= $id ?>"
|
data-id="<?= $id ?>"
|
||||||
@@ -377,7 +459,8 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
data-rem="<?= $rem ?>"
|
data-rem="<?= $rem ?>"
|
||||||
data-sort_order="<?= $sortOrder ?>"
|
data-sort_order="<?= $sortOrder ?>"
|
||||||
data-is_active="<?= $isActive ?>"
|
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
|
✏️ Modifica
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger delete-topic"
|
<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>Formazioni:</b> <?= $cnt ?></span>
|
||||||
<span><b>Ordine:</b> <?= $sortOrder ?></span>
|
<span><b>Ordine:</b> <?= $sortOrder ?></span>
|
||||||
</div>
|
</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">
|
<div class="tt-card-actions">
|
||||||
<button class="btn btn-sm btn-outline-secondary edit-topic"
|
<button class="btn btn-sm btn-outline-secondary edit-topic"
|
||||||
data-id="<?= $id ?>"
|
data-id="<?= $id ?>"
|
||||||
@@ -439,7 +533,8 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
data-rem="<?= $rem ?>"
|
data-rem="<?= $rem ?>"
|
||||||
data-sort_order="<?= $sortOrder ?>"
|
data-sort_order="<?= $sortOrder ?>"
|
||||||
data-is_active="<?= $isActive ?>"
|
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
|
✏️ Modifica
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger delete-topic"
|
<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.
|
Se attivo, i dipendenti senza registrazione di questo corso compaiono come "Non presente" nello storico.
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="text-center">
|
||||||
<button type="submit" class="btn btn-add">💾 Salva</button>
|
<button type="submit" class="btn btn-add">💾 Salva</button>
|
||||||
</div>
|
</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.
|
Se attivo, i dipendenti senza registrazione di questo corso compaiono come "Non presente" nello storico.
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="text-center">
|
||||||
<button type="submit" class="btn btn-add">💾 Salva Modifiche</button>
|
<button type="submit" class="btn btn-add">💾 Salva Modifiche</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -604,6 +724,10 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
<script>
|
<script>
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
$('#tabellaTopics').DataTable({
|
$('#tabellaTopics').DataTable({
|
||||||
|
columnDefs: [{
|
||||||
|
targets: [8, 9],
|
||||||
|
orderable: false
|
||||||
|
}],
|
||||||
order: [
|
order: [
|
||||||
[5, 'asc'],
|
[5, 'asc'],
|
||||||
[1, 'asc']
|
[1, 'asc']
|
||||||
@@ -660,6 +784,9 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
p.append('sort_order', $("#addSortOrder").val());
|
p.append('sort_order', $("#addSortOrder").val());
|
||||||
p.append('is_active', $("#addIsActive").val());
|
p.append('is_active', $("#addIsActive").val());
|
||||||
p.append('is_mandatory', $("#addIsMandatory").is(':checked') ? '1' : '0');
|
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.");
|
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"));
|
$("#editSortOrder").val(b.data("sort_order"));
|
||||||
$("#editIsActive").val(String(b.data("is_active")));
|
$("#editIsActive").val(String(b.data("is_active")));
|
||||||
$("#editIsMandatory").prop('checked', String(b.data("is_mandatory")) === '1');
|
$("#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");
|
$("#editTopicModal").modal("show");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -692,6 +837,9 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
|||||||
p.append('sort_order', $("#editSortOrder").val());
|
p.append('sort_order', $("#editSortOrder").val());
|
||||||
p.append('is_active', $("#editIsActive").val());
|
p.append('is_active', $("#editIsActive").val());
|
||||||
p.append('is_mandatory', $("#editIsMandatory").is(':checked') ? '1' : '0');
|
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.");
|
ajaxPost("ajax/training_topics/save.php", p, "Aggiornato!", "Impossibile aggiornare il corso.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ $stmt = $pdo->prepare("
|
|||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$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 */
|
/* Filter by computed status */
|
||||||
function trainingStatus(?string $nextDue, ?int $reminderDays, ?int $topicDefaultRem): array
|
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];
|
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 = [];
|
$filtered = [];
|
||||||
$counters = ['compliant' => 0, 'due_soon' => 0, 'expired' => 0, 'not_present' => 0, 'all' => 0];
|
$counters = ['compliant' => 0, 'due_soon' => 0, 'expired' => 0, 'not_present' => 0, 'all' => 0];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
@@ -106,6 +169,21 @@ foreach ($rows as $r) {
|
|||||||
$filtered[] = $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.
|
"NOT PRESENT" — mandatory topics without any record for an employee.
|
||||||
Apply the same filters (employee_id / topic_id / department_id / type=initial).
|
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);
|
$missingRows = $missingStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
foreach ($missingRows as $m) {
|
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['all']++;
|
||||||
$counters['not_present']++;
|
$counters['not_present']++;
|
||||||
if ($fStatus !== '' && $fStatus !== 'not_present') continue;
|
if ($fStatus !== '' && $fStatus !== 'not_present') continue;
|
||||||
@@ -421,6 +502,19 @@ function fmtDate(?string $d): string
|
|||||||
border: 1px solid #cbd5e1;
|
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 {
|
.pill-dept-inline {
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
}
|
}
|
||||||
@@ -617,7 +711,7 @@ function fmtDate(?string $d): string
|
|||||||
$typeLbl = $r['training_type'] === 'refresher' ? 'Aggiornamento' : ($r['training_type'] === 'initial' ? 'Iniziale' : '—');
|
$typeLbl = $r['training_type'] === 'refresher' ? 'Aggiornamento' : ($r['training_type'] === 'initial' ? 'Iniziale' : '—');
|
||||||
$days = $r['_status']['days'] ?? null;
|
$days = $r['_status']['days'] ?? null;
|
||||||
?>
|
?>
|
||||||
<tr>
|
<tr class="<?= !empty($r['_covered']) ? 'row-covered' : '' ?>">
|
||||||
<td>
|
<td>
|
||||||
<?php if (!empty($r['id'])): ?>
|
<?php if (!empty($r['id'])): ?>
|
||||||
<input type="checkbox" class="form-check-input row-check" value="<?= (int)$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>
|
</span>
|
||||||
<?php else: ?>—<?php endif; ?>
|
<?php else: ?>—<?php endif; ?>
|
||||||
</td>
|
</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><span class="pill pill-role"><?= $typeLbl ?></span></td>
|
||||||
<td><?= fmtDate($r['completed_date']) ?></td>
|
<td><?= fmtDate($r['completed_date']) ?></td>
|
||||||
<td><?= fmtDate($r['next_due_date']) ?></td>
|
<td><?= fmtDate($r['next_due_date']) ?></td>
|
||||||
@@ -677,7 +778,14 @@ function fmtDate(?string $d): string
|
|||||||
</div>
|
</div>
|
||||||
<span class="pill pill-<?= $r['_status']['class'] ?>"><?= $r['_status']['label'] ?></span>
|
<span class="pill pill-<?= $r['_status']['class'] ?>"><?= $r['_status']['label'] ?></span>
|
||||||
</div>
|
</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">
|
<div class="meta">
|
||||||
<span><b>Tipo:</b> <?= $typeLbl ?></span>
|
<span><b>Tipo:</b> <?= $typeLbl ?></span>
|
||||||
<span><b>Completato:</b> <?= fmtDate($r['completed_date']) ?></span>
|
<span><b>Completato:</b> <?= fmtDate($r['completed_date']) ?></span>
|
||||||
|
|||||||
Reference in New Issue
Block a user