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
|
||||
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()]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user