bulk operations
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* Bulk "renew": set a common completed_date on the selected training records
|
||||
*/
|
||||
require_once(__DIR__ . '/../hr_auth_check.php');
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'message' => 'Metodo non consentito.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// $pdo and $currentUserId from hr_auth_check.php
|
||||
|
||||
$completedDate = trim($_POST['completed_date'] ?? '');
|
||||
$ids = $_POST['training_ids'] ?? [];
|
||||
|
||||
if (!is_array($ids)) {
|
||||
$ids = [];
|
||||
}
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
|
||||
|
||||
if ($completedDate === '' || !DateTime::createFromFormat('Y-m-d', $completedDate)) {
|
||||
echo json_encode(['success' => false, 'message' => 'Indicare una data valida.']);
|
||||
exit;
|
||||
}
|
||||
if (empty($ids)) {
|
||||
echo json_encode(['success' => false, 'message' => 'Selezionare almeno un record.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// Load each record with its topic default frequency
|
||||
$rowStmt = $pdo->prepare("
|
||||
SELECT et.id, et.employee_id, et.completed_date, et.next_due_date,
|
||||
et.update_frequency_months, tt.default_frequency_months
|
||||
FROM employee_trainings et
|
||||
JOIN training_topics tt ON tt.id = et.training_topic_id
|
||||
WHERE et.id = :id
|
||||
");
|
||||
$upd = $pdo->prepare("
|
||||
UPDATE employee_trainings
|
||||
SET completed_date = :cd, next_due_date = :nd, updated_at = NOW()
|
||||
WHERE id = :id
|
||||
");
|
||||
$logStmt = $pdo->prepare("
|
||||
INSERT INTO employee_training_log
|
||||
(employee_id, training_id, action, field, old_value, new_value, changed_by, changed_at)
|
||||
VALUES
|
||||
(:eid, :tid, 'updated', :field, :old_v, :new_v, :cb, NOW())
|
||||
");
|
||||
|
||||
$updated = 0;
|
||||
foreach ($ids as $id) {
|
||||
$rowStmt->execute(['id' => $id]);
|
||||
$row = $rowStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Effective frequency: per-record override, else topic default
|
||||
$effFreq = $row['update_frequency_months'] !== null
|
||||
? (int)$row['update_frequency_months']
|
||||
: ($row['default_frequency_months'] !== null ? (int)$row['default_frequency_months'] : null);
|
||||
|
||||
$nextDue = null;
|
||||
if ($effFreq !== null && $effFreq > 0) {
|
||||
$d = DateTime::createFromFormat('Y-m-d', $completedDate);
|
||||
if ($d) {
|
||||
$d->modify('+' . $effFreq . ' months');
|
||||
$nextDue = $d->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
$upd->execute(['cd' => $completedDate, 'nd' => $nextDue, 'id' => $id]);
|
||||
|
||||
if ((string)$row['completed_date'] !== (string)$completedDate) {
|
||||
$logStmt->execute([
|
||||
'eid' => $row['employee_id'], 'tid' => $id, 'field' => 'completed_date',
|
||||
'old_v' => $row['completed_date'], 'new_v' => $completedDate, 'cb' => $currentUserId,
|
||||
]);
|
||||
}
|
||||
if ((string)$row['next_due_date'] !== (string)$nextDue) {
|
||||
$logStmt->execute([
|
||||
'eid' => $row['employee_id'], 'tid' => $id, 'field' => 'next_due_date',
|
||||
'old_v' => $row['next_due_date'], 'new_v' => $nextDue, 'cb' => $currentUserId,
|
||||
]);
|
||||
}
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'updated' => $updated,
|
||||
'message' => $updated . ' record aggiornat' . ($updated === 1 ? 'o' : 'i') . '.',
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
/**
|
||||
* Calendar events for the training calendar (training_calendar.php).
|
||||
* Returns FullCalendar event objects for the *current* training record per
|
||||
* (employee, topic) that has a next_due_date, colored by computed status.
|
||||
* HR-only.
|
||||
*/
|
||||
require_once(__DIR__ . '/../hr_auth_check.php');
|
||||
header('Content-Type: application/json');
|
||||
|
||||
try {
|
||||
// $pdo and $currentUserId provided by hr_auth_check.php
|
||||
|
||||
$start = $_GET['start'] ?? null;
|
||||
$end = $_GET['end'] ?? null;
|
||||
$fStatus = isset($_GET['status']) ? trim($_GET['status']) : '';
|
||||
$fDept = isset($_GET['department_id']) && $_GET['department_id'] !== '' ? (int)$_GET['department_id'] : 0;
|
||||
$fTopic = isset($_GET['topic_id']) && $_GET['topic_id'] !== '' ? (int)$_GET['topic_id'] : 0;
|
||||
$fEmp = isset($_GET['employee_id']) && $_GET['employee_id'] !== '' ? (int)$_GET['employee_id'] : 0;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
// Deadlines only (one-time trainings have no next_due_date)
|
||||
$where[] = "et.next_due_date IS NOT NULL";
|
||||
|
||||
// Only the most recent record per (employee, topic)
|
||||
$where[] = "NOT EXISTS (
|
||||
SELECT 1 FROM employee_trainings et2
|
||||
WHERE et2.employee_id = et.employee_id
|
||||
AND et2.training_topic_id = et.training_topic_id
|
||||
AND (et2.completed_date > et.completed_date
|
||||
OR (et2.completed_date = et.completed_date AND et2.id > et.id))
|
||||
)";
|
||||
|
||||
if ($start && $end) {
|
||||
$where[] = "et.next_due_date >= :start AND et.next_due_date <= :end";
|
||||
$params['start'] = $start;
|
||||
$params['end'] = $end;
|
||||
}
|
||||
if ($fDept > 0) { $where[] = "e.department_id = :did"; $params['did'] = $fDept; }
|
||||
if ($fTopic > 0) { $where[] = "et.training_topic_id = :tid"; $params['tid'] = $fTopic; }
|
||||
if ($fEmp > 0) { $where[] = "et.employee_id = :eid"; $params['eid'] = $fEmp; }
|
||||
|
||||
$whereSql = 'WHERE ' . implode(' AND ', $where);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT et.id, et.employee_id, et.next_due_date, et.reminder_days,
|
||||
tt.name AS topic_name, tt.default_reminder_days AS topic_default_rem,
|
||||
e.first_name, e.last_name
|
||||
FROM employee_trainings et
|
||||
JOIN training_topics tt ON tt.id = et.training_topic_id
|
||||
JOIN employees e ON e.id = et.employee_id
|
||||
$whereSql
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$today = new DateTime('today');
|
||||
$events = [];
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$rem = $r['reminder_days'] !== null
|
||||
? (int)$r['reminder_days']
|
||||
: ($r['topic_default_rem'] !== null ? (int)$r['topic_default_rem'] : 30);
|
||||
|
||||
$due = DateTime::createFromFormat('Y-m-d', $r['next_due_date']);
|
||||
if (!$due) continue;
|
||||
$daysLeft = (int)$today->diff($due)->format('%r%a');
|
||||
|
||||
if ($daysLeft < 0) { $code = 'expired'; $color = '#dc3545'; }
|
||||
elseif ($daysLeft <= $rem){ $code = 'due_soon'; $color = '#e8930c'; }
|
||||
else { $code = 'compliant'; $color = '#198754'; }
|
||||
|
||||
if ($fStatus !== '' && $fStatus !== $code) continue;
|
||||
|
||||
$name = trim($r['first_name'] . ' ' . $r['last_name']);
|
||||
$events[] = [
|
||||
'id' => (int)$r['id'],
|
||||
'title' => $name . ' — ' . $r['topic_name'],
|
||||
'start' => $r['next_due_date'],
|
||||
'allDay' => true,
|
||||
'backgroundColor' => $color,
|
||||
'borderColor' => $color,
|
||||
'url' => 'employee-profile.php?id=' . (int)$r['employee_id'] . '#tab-training',
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($events);
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([]);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* Bulk-create training records: one employee_trainings row per selected employee,
|
||||
* all sharing the same course + parameters (a single training "session").
|
||||
* Mirrors the next_due_date logic of ajax/employee_profile/save_training.php.
|
||||
* HR-only.
|
||||
*/
|
||||
require_once(__DIR__ . '/../hr_auth_check.php');
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'message' => 'Metodo non consentito.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// $pdo and $currentUserId from hr_auth_check.php
|
||||
|
||||
$topicId = (int)($_POST['training_topic_id'] ?? 0);
|
||||
$completedDate = trim($_POST['completed_date'] ?? '');
|
||||
$deliveredBy = trim($_POST['delivered_by'] ?? '');
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$trainingType = trim($_POST['training_type'] ?? 'initial');
|
||||
$freqRaw = $_POST['update_frequency_months'] ?? '';
|
||||
$remRaw = $_POST['reminder_days'] ?? '';
|
||||
$employeeIds = $_POST['employee_ids'] ?? [];
|
||||
|
||||
if (!is_array($employeeIds)) {
|
||||
$employeeIds = [];
|
||||
}
|
||||
$employeeIds = array_values(array_unique(array_filter(array_map('intval', $employeeIds), fn($v) => $v > 0)));
|
||||
|
||||
if ($topicId <= 0) {
|
||||
echo json_encode(['success' => false, 'message' => 'Selezionare un corso.']);
|
||||
exit;
|
||||
}
|
||||
if ($completedDate === '' || !DateTime::createFromFormat('Y-m-d', $completedDate)) {
|
||||
echo json_encode(['success' => false, 'message' => 'La data di completamento è obbligatoria.']);
|
||||
exit;
|
||||
}
|
||||
if (empty($employeeIds)) {
|
||||
echo json_encode(['success' => false, 'message' => 'Selezionare almeno un dipendente.']);
|
||||
exit;
|
||||
}
|
||||
if (!in_array($trainingType, ['initial', 'refresher'], true)) {
|
||||
$trainingType = 'initial';
|
||||
}
|
||||
|
||||
$topicStmt = $pdo->prepare("SELECT default_frequency_months, default_reminder_days FROM training_topics WHERE id = :id");
|
||||
$topicStmt->execute(['id' => $topicId]);
|
||||
$topic = $topicStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$topic) {
|
||||
echo json_encode(['success' => false, 'message' => 'Corso non trovato.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$freq = ($freqRaw === '' || $freqRaw === null) ? null : max(0, (int)$freqRaw);
|
||||
$rem = ($remRaw === '' || $remRaw === null) ? null : max(0, (int)$remRaw);
|
||||
|
||||
/* Effective frequency → next_due_date (same for every employee: same date + same frequency) */
|
||||
$effFreq = $freq !== null ? $freq : ($topic['default_frequency_months'] !== null ? (int)$topic['default_frequency_months'] : null);
|
||||
$nextDue = null;
|
||||
if ($effFreq !== null && $effFreq > 0) {
|
||||
$d = DateTime::createFromFormat('Y-m-d', $completedDate);
|
||||
if ($d) {
|
||||
$d->modify('+' . (int)$effFreq . ' months');
|
||||
$nextDue = $d->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
$deliveredBy = $deliveredBy !== '' ? $deliveredBy : null;
|
||||
$description = $description !== '' ? $description : null;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// Only insert for employees that actually exist
|
||||
$checkEmp = $pdo->prepare("SELECT id FROM employees WHERE id = :id");
|
||||
|
||||
$ins = $pdo->prepare("
|
||||
INSERT INTO employee_trainings
|
||||
(employee_id, training_topic_id, completed_date,
|
||||
delivered_by, description,
|
||||
training_type, update_frequency_months, reminder_days, next_due_date,
|
||||
created_by, created_at, updated_at)
|
||||
VALUES
|
||||
(:eid, :tid, :completed_date,
|
||||
:delivered_by, :description,
|
||||
:training_type, :freq, :rem, :next_due,
|
||||
:cb, NOW(), NOW())
|
||||
");
|
||||
$logStmt = $pdo->prepare("
|
||||
INSERT INTO employee_training_log
|
||||
(employee_id, training_id, action, field, old_value, new_value, changed_by, changed_at)
|
||||
VALUES
|
||||
(:eid, :tid, 'created', NULL, NULL, NULL, :cb, NOW())
|
||||
");
|
||||
|
||||
$created = 0;
|
||||
foreach ($employeeIds as $eid) {
|
||||
$checkEmp->execute(['id' => $eid]);
|
||||
if (!$checkEmp->fetchColumn()) {
|
||||
continue;
|
||||
}
|
||||
$ins->execute([
|
||||
'eid' => $eid,
|
||||
'tid' => $topicId,
|
||||
'completed_date' => $completedDate,
|
||||
'delivered_by' => $deliveredBy,
|
||||
'description' => $description,
|
||||
'training_type' => $trainingType,
|
||||
'freq' => $freq,
|
||||
'rem' => $rem,
|
||||
'next_due' => $nextDue,
|
||||
'cb' => $currentUserId,
|
||||
]);
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
$logStmt->execute(['eid' => $eid, 'tid' => $newId, 'cb' => $currentUserId]);
|
||||
$created++;
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'created' => $created,
|
||||
'message' => $created . ' formazion' . ($created === 1 ? 'e registrata' : 'i registrate') . '.',
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
@@ -321,6 +321,11 @@
|
||||
<i class='bx bx-radio-circle'></i>Storico Formazione
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="training_calendar.php">
|
||||
<i class='bx bx-radio-circle'></i>Calendario Formazione
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('hr.skills.view')) : ?>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
include('include/headscript.php');
|
||||
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
|
||||
/* ==========================================
|
||||
PERMISSIONS (mirror trainings.php)
|
||||
========================================== */
|
||||
$isHrManager = Auth::user()->hasRole('Admin')
|
||||
|| Auth::user()->hasRole('Superuser')
|
||||
|| Auth::user()->hasRole('employee-hr')
|
||||
|| Auth::user()->hasRole('manager');
|
||||
|
||||
if (!$isHrManager) {
|
||||
header('Location: employee-profile.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
/* Dropdown data */
|
||||
$employees = $pdo->query("
|
||||
SELECT id, first_name, last_name, employee_code
|
||||
FROM employees
|
||||
ORDER BY last_name, first_name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$topics = $pdo->query("
|
||||
SELECT id, name FROM training_topics WHERE is_active = 1 ORDER BY sort_order, name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$departments = $pdo->query("
|
||||
SELECT id, name FROM departments WHERE is_active = 1 ORDER BY sort_order, name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
|
||||
<?php include('cssinclude.php'); ?>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.9/index.global.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.9/locales/it.global.min.js"></script>
|
||||
<title>Calendario Formazione - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
|
||||
|
||||
<style>
|
||||
body { font-size: 1.05rem; background: #f8fafc; }
|
||||
.card { border-radius: 16px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
|
||||
.back-dashboard {
|
||||
background-color: #cfe3ff !important; color: #1f2d3d !important;
|
||||
border: 1px solid #bcd4f4 !important; border-radius: 10px;
|
||||
font-weight: 600; padding: 10px 18px;
|
||||
}
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 1rem; }
|
||||
.legend-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: #64748b; }
|
||||
.legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
/* FullCalendar overrides */
|
||||
.fc { font-size: 0.95rem; }
|
||||
.fc .fc-toolbar-title { font-size: 1.15rem; font-weight: 700; color: #2c3e6b; }
|
||||
.fc .fc-button-primary {
|
||||
background: #5a8fd8; border-color: #5a8fd8;
|
||||
font-weight: 600; font-size: 0.82rem; border-radius: 0.4rem;
|
||||
}
|
||||
.fc .fc-button-primary:hover { background: #4578c0; border-color: #4578c0; }
|
||||
.fc .fc-button-primary:disabled { background: #9bbce6; border-color: #9bbce6; }
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active { background: #2c3e6b; border-color: #2c3e6b; }
|
||||
.fc .fc-daygrid-day-number { color: #2c3e6b; font-weight: 500; }
|
||||
.fc .fc-daygrid-day.fc-day-today { background: #f0f4ff; }
|
||||
.fc .fc-event { border-radius: 0.3rem; padding: 2px 4px; font-weight: 600; cursor: pointer; }
|
||||
.fc .fc-event:hover { filter: brightness(0.92); }
|
||||
.fc .fc-list-event:hover td { background: #f0f4ff; }
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.card-header { flex-direction: column; align-items: flex-start !important; gap: .5rem; }
|
||||
.fc .fc-toolbar { flex-direction: column; gap: 0.5rem; }
|
||||
.fc .fc-toolbar-title { font-size: 1rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrapper" id="appWrapper">
|
||||
<?php include('include/navbar.php'); ?>
|
||||
<?php include('include/topbar.php'); ?>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="page-content">
|
||||
<div class="card p-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<h5 class="mb-0">📅 Calendario Formazione</h5>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a href="trainings.php" class="btn btn-light border d-inline-flex align-items-center gap-2">
|
||||
📚 <span>Storico Formazione</span>
|
||||
</a>
|
||||
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
|
||||
↩️ Torna alla Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- FILTERS -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Stato</label>
|
||||
<select id="filterStatus" class="form-select">
|
||||
<option value="">Tutti</option>
|
||||
<option value="expired">Scaduti</option>
|
||||
<option value="due_soon">Da aggiornare</option>
|
||||
<option value="compliant">Conformi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Corso</label>
|
||||
<select id="filterTopic" class="form-select">
|
||||
<option value="">Tutti</option>
|
||||
<?php foreach ($topics as $t): ?>
|
||||
<option value="<?= (int)$t['id'] ?>"><?= htmlspecialchars($t['name'], ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Reparto</label>
|
||||
<select id="filterDepartment" class="form-select">
|
||||
<option value="">Tutti</option>
|
||||
<?php foreach ($departments as $d): ?>
|
||||
<option value="<?= (int)$d['id'] ?>"><?= htmlspecialchars($d['name'], ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Dipendente</label>
|
||||
<div class="d-flex gap-2">
|
||||
<select id="filterEmployee" class="form-select">
|
||||
<option value="">Tutti</option>
|
||||
<?php foreach ($employees as $e): ?>
|
||||
<option value="<?= (int)$e['id'] ?>"><?= htmlspecialchars(trim($e['last_name'] . ' ' . $e['first_name']), ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button id="btnResetFilters" type="button" class="btn btn-light border flex-shrink-0" title="Reset filtri">
|
||||
<i class="fa-solid fa-rotate-left"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LEGEND -->
|
||||
<div class="legend">
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#dc3545"></span> Scaduto</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#e8930c"></span> Da aggiornare</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#198754"></span> Conforme</div>
|
||||
</div>
|
||||
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include('include/footer.php'); ?>
|
||||
</div>
|
||||
|
||||
<?php include('jsinclude.php'); ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var isMobile = window.innerWidth < 768;
|
||||
var calendarEl = document.getElementById('calendar');
|
||||
|
||||
var calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
locale: 'it',
|
||||
initialView: isMobile ? 'listMonth' : 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: isMobile ? 'listMonth,dayGridMonth' : 'dayGridMonth,listMonth'
|
||||
},
|
||||
height: 'auto',
|
||||
navLinks: true,
|
||||
eventSources: [{
|
||||
url: 'ajax/trainings/calendar_events.php',
|
||||
extraParams: function() {
|
||||
return {
|
||||
status: document.getElementById('filterStatus').value,
|
||||
topic_id: document.getElementById('filterTopic').value,
|
||||
department_id: document.getElementById('filterDepartment').value,
|
||||
employee_id: document.getElementById('filterEmployee').value
|
||||
};
|
||||
},
|
||||
failure: function() {
|
||||
if (window.Swal) Swal.fire('Errore', 'Impossibile caricare gli eventi.', 'error');
|
||||
}
|
||||
}],
|
||||
eventClick: function(info) {
|
||||
info.jsEvent.preventDefault();
|
||||
if (info.event.url) window.location.href = info.event.url;
|
||||
},
|
||||
windowResize: function() {
|
||||
calendar.changeView(window.innerWidth < 768 ? 'listMonth' : 'dayGridMonth');
|
||||
}
|
||||
});
|
||||
calendar.render();
|
||||
|
||||
document.querySelectorAll('#filterStatus, #filterTopic, #filterDepartment, #filterEmployee').forEach(function(el) {
|
||||
el.addEventListener('change', function() { calendar.refetchEvents(); });
|
||||
});
|
||||
|
||||
document.getElementById('btnResetFilters').addEventListener('click', function() {
|
||||
['filterStatus', 'filterTopic', 'filterDepartment', 'filterEmployee'].forEach(function(id) {
|
||||
document.getElementById(id).value = '';
|
||||
});
|
||||
calendar.refetchEvents();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -151,12 +151,13 @@ if ($fType === '' || $fType === 'initial') {
|
||||
|
||||
/* Dropdown data */
|
||||
$employees = $pdo->query("
|
||||
SELECT id, first_name, last_name, employee_code
|
||||
SELECT id, first_name, last_name, employee_code, department_id
|
||||
FROM employees
|
||||
ORDER BY last_name, first_name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$topics = $pdo->query("
|
||||
SELECT id, name FROM training_topics WHERE is_active = 1 ORDER BY sort_order, name
|
||||
SELECT id, name, default_frequency_months, default_reminder_days
|
||||
FROM training_topics WHERE is_active = 1 ORDER BY sort_order, name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$departments = $pdo->query("
|
||||
SELECT id, name, color FROM departments WHERE is_active = 1 ORDER BY sort_order, name
|
||||
@@ -180,6 +181,9 @@ function fmtDate(?string $d): string {
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
|
||||
<style>
|
||||
body { font-size: 1.05rem; background: #f8fafc; }
|
||||
@@ -243,9 +247,14 @@ function fmtDate(?string $d): string {
|
||||
<div class="card p-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<h5 class="mb-0">📚 Storico Formazione</h5>
|
||||
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
|
||||
↩️ Torna alla Dashboard
|
||||
</button>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button type="button" class="btn btn-primary" id="btnBulkTraining">
|
||||
➕ Aggiungi sessione
|
||||
</button>
|
||||
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
|
||||
↩️ Torna alla Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
@@ -325,6 +334,12 @@ function fmtDate(?string $d): string {
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<div id="bulkBar" class="d-none align-items-center flex-wrap gap-2 mb-3 p-2" style="background:#fff6e5;border:1px solid #ffe0a6;border-radius:10px;">
|
||||
<span class="fw-semibold"><span id="bulkSelCount">0</span> selezionati</span>
|
||||
<button type="button" class="btn btn-sm btn-warning" id="btnBulkRenew">🔄 Aggiorna scadenza</button>
|
||||
<button type="button" class="btn btn-sm btn-link text-decoration-none" id="btnBulkDeselect">Deseleziona</button>
|
||||
</div>
|
||||
|
||||
<?php if (empty($filtered)): ?>
|
||||
<div class="text-center text-muted py-4">
|
||||
Nessuna formazione corrispondente ai filtri.
|
||||
@@ -335,6 +350,7 @@ function fmtDate(?string $d): string {
|
||||
<table class="table table-striped align-middle">
|
||||
<thead style="background-color:#cfe3ff;">
|
||||
<tr>
|
||||
<th style="width:36px"><input type="checkbox" class="form-check-input" id="checkAll" title="Seleziona tutti"></th>
|
||||
<th>Dipendente</th>
|
||||
<th>Reparto</th>
|
||||
<th>Corso</th>
|
||||
@@ -353,6 +369,11 @@ function fmtDate(?string $d): string {
|
||||
$days = $r['_status']['days'] ?? null;
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<?php if (!empty($r['id'])): ?>
|
||||
<input type="checkbox" class="form-check-input row-check" value="<?= (int)$r['id'] ?>">
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<a href="employee-profile.php?id=<?= (int)$r['employee_id'] ?>#tab-training" class="fw-semibold text-decoration-none">
|
||||
<?= htmlspecialchars($fullName) ?>
|
||||
@@ -397,7 +418,10 @@ function fmtDate(?string $d): string {
|
||||
?>
|
||||
<div class="tr-card">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2 mb-1">
|
||||
<div class="name">
|
||||
<div class="name d-flex align-items-start gap-2">
|
||||
<?php if (!empty($r['id'])): ?>
|
||||
<input type="checkbox" class="form-check-input row-check mt-1" value="<?= (int)$r['id'] ?>">
|
||||
<?php endif; ?>
|
||||
<a href="employee-profile.php?id=<?= (int)$r['employee_id'] ?>#tab-training">
|
||||
<?= htmlspecialchars($fullName) ?>
|
||||
</a>
|
||||
@@ -434,6 +458,296 @@ function fmtDate(?string $d): string {
|
||||
<?php include('include/footer.php'); ?>
|
||||
</div>
|
||||
|
||||
<!-- BULK TRAINING SESSION MODAL -->
|
||||
<div class="modal fade" id="bulkTrainingModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">➕ Nuova sessione formativa</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||||
</div>
|
||||
<form id="bulkTrainingForm">
|
||||
<div class="modal-body" style="max-height:65vh; overflow-y:auto;">
|
||||
<p class="text-muted small">Registra lo stesso corso, con gli stessi parametri, per più dipendenti contemporaneamente.</p>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Corso <span class="text-danger">*</span></label>
|
||||
<select id="bulkTopic" class="form-select" required>
|
||||
<option value="">— Seleziona —</option>
|
||||
<?php foreach ($topics as $t): ?>
|
||||
<option value="<?= (int)$t['id'] ?>"
|
||||
data-freq="<?= $t['default_frequency_months'] !== null ? (int)$t['default_frequency_months'] : '' ?>"
|
||||
data-rem="<?= $t['default_reminder_days'] !== null ? (int)$t['default_reminder_days'] : '' ?>">
|
||||
<?= htmlspecialchars($t['name'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label fw-semibold">Data completamento <span class="text-danger">*</span></label>
|
||||
<input type="date" id="bulkCompletedDate" class="form-control" value="<?= date('Y-m-d') ?>" required>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label fw-semibold">Tipo</label>
|
||||
<select id="bulkType" class="form-select">
|
||||
<option value="initial">Iniziale</option>
|
||||
<option value="refresher">Aggiornamento</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label fw-semibold">Frequenza (mesi)</label>
|
||||
<input type="number" id="bulkFreq" class="form-control" min="0" max="600" placeholder="default corso">
|
||||
<div class="form-text">Vuoto = una tantum</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label fw-semibold">Promemoria (giorni)</label>
|
||||
<input type="number" id="bulkRem" class="form-control" min="0" max="365" placeholder="default corso">
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Erogato da</label>
|
||||
<input type="text" id="bulkDeliveredBy" class="form-control" maxlength="255" placeholder="es. Ente formatore, docente interno...">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Descrizione / note</label>
|
||||
<textarea id="bulkDescription" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12"><hr class="my-1"></div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Dipendenti <span class="text-danger">*</span></label>
|
||||
<div class="d-flex flex-wrap gap-2 mb-2 align-items-end">
|
||||
<div>
|
||||
<select id="bulkDept" class="form-select form-select-sm" style="min-width:180px">
|
||||
<option value="">— Reparto —</option>
|
||||
<?php foreach ($departments as $d): ?>
|
||||
<option value="<?= (int)$d['id'] ?>"><?= htmlspecialchars($d['name'], ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="bulkAddDept">+ Aggiungi reparto</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulkSelectAll">Tutti</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulkClear">Pulisci</button>
|
||||
</div>
|
||||
<select id="bulkEmployees" class="form-select" multiple required>
|
||||
<?php foreach ($employees as $e): ?>
|
||||
<option value="<?= (int)$e['id'] ?>" data-dept="<?= (int)($e['department_id'] ?? 0) ?>">
|
||||
<?= htmlspecialchars(trim($e['last_name'] . ' ' . $e['first_name']), ENT_QUOTES, 'UTF-8') ?><?php if (!empty($e['employee_code'])): ?> (<?= htmlspecialchars($e['employee_code'], ENT_QUOTES, 'UTF-8') ?>)<?php endif; ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text"><span id="bulkCount">0</span> dipendenti selezionati</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light border" data-bs-dismiss="modal">Annulla</button>
|
||||
<button type="submit" class="btn btn-primary" id="bulkSaveBtn">Registra formazione</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include('jsinclude.php'); ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var bulkModal = new bootstrap.Modal(document.getElementById('bulkTrainingModal'));
|
||||
var $emp = $('#bulkEmployees');
|
||||
|
||||
$emp.select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: 'Seleziona dipendenti...',
|
||||
dropdownParent: $('#bulkTrainingModal'),
|
||||
closeOnSelect: false,
|
||||
width: '100%'
|
||||
});
|
||||
|
||||
function updateCount() {
|
||||
document.getElementById('bulkCount').textContent = ($emp.val() || []).length;
|
||||
}
|
||||
$emp.on('change', updateCount);
|
||||
|
||||
document.getElementById('btnBulkTraining').addEventListener('click', function() {
|
||||
document.getElementById('bulkTrainingForm').reset();
|
||||
$emp.val(null).trigger('change');
|
||||
document.getElementById('bulkTopic').value = '';
|
||||
document.getElementById('bulkType').value = 'initial';
|
||||
updateCount();
|
||||
bulkModal.show();
|
||||
});
|
||||
|
||||
// Prefill frequency/reminder from the selected course
|
||||
document.getElementById('bulkTopic').addEventListener('change', function() {
|
||||
var opt = this.options[this.selectedIndex];
|
||||
document.getElementById('bulkFreq').value = opt ? (opt.getAttribute('data-freq') || '') : '';
|
||||
document.getElementById('bulkRem').value = opt ? (opt.getAttribute('data-rem') || '') : '';
|
||||
});
|
||||
|
||||
// Add all employees of the chosen department to the selection
|
||||
document.getElementById('bulkAddDept').addEventListener('click', function() {
|
||||
var dept = document.getElementById('bulkDept').value;
|
||||
if (!dept) return;
|
||||
var current = ($emp.val() || []).map(String);
|
||||
$emp.find('option').each(function() {
|
||||
if (this.getAttribute('data-dept') === String(dept) && current.indexOf(this.value) === -1) {
|
||||
current.push(this.value);
|
||||
}
|
||||
});
|
||||
$emp.val(current).trigger('change');
|
||||
});
|
||||
|
||||
document.getElementById('bulkSelectAll').addEventListener('click', function() {
|
||||
var all = $emp.find('option').map(function() { return this.value; }).get();
|
||||
$emp.val(all).trigger('change');
|
||||
});
|
||||
document.getElementById('bulkClear').addEventListener('click', function() {
|
||||
$emp.val(null).trigger('change');
|
||||
});
|
||||
|
||||
document.getElementById('bulkTrainingForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var topicId = document.getElementById('bulkTopic').value;
|
||||
var completed = document.getElementById('bulkCompletedDate').value;
|
||||
var emps = $emp.val() || [];
|
||||
|
||||
if (!topicId) { Swal.fire('Attenzione', 'Selezionare un corso.', 'warning'); return; }
|
||||
if (!completed) { Swal.fire('Attenzione', 'Indicare la data di completamento.', 'warning'); return; }
|
||||
if (emps.length === 0) { Swal.fire('Attenzione', 'Selezionare almeno un dipendente.', 'warning'); return; }
|
||||
|
||||
var btn = document.getElementById('bulkSaveBtn');
|
||||
btn.disabled = true;
|
||||
var orig = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Salvataggio...';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('training_topic_id', topicId);
|
||||
fd.append('completed_date', completed);
|
||||
fd.append('training_type', document.getElementById('bulkType').value);
|
||||
fd.append('delivered_by', document.getElementById('bulkDeliveredBy').value);
|
||||
fd.append('description', document.getElementById('bulkDescription').value);
|
||||
fd.append('update_frequency_months', document.getElementById('bulkFreq').value);
|
||||
fd.append('reminder_days', document.getElementById('bulkRem').value);
|
||||
emps.forEach(function(id) { fd.append('employee_ids[]', id); });
|
||||
|
||||
fetch('ajax/trainings/save_bulk_training.php', { method: 'POST', body: fd })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
bulkModal.hide();
|
||||
Swal.fire({ icon: 'success', title: 'Fatto', text: data.message, timer: 1800, showConfirmButton: false })
|
||||
.then(function() { location.reload(); });
|
||||
} else {
|
||||
btn.disabled = false; btn.innerHTML = orig;
|
||||
Swal.fire('Errore', data.message || 'Errore.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btn.disabled = false; btn.innerHTML = orig;
|
||||
Swal.fire('Errore', 'Errore di connessione.', 'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- BULK RENEW DEADLINE MODAL -->
|
||||
<div class="modal fade" id="bulkRenewModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<form id="bulkRenewForm">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">🔄 Aggiorna scadenza</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small">Imposta la data di completamento per <b id="renewCount">0</b> record selezionati. Le prossime scadenze verranno ricalcolate in base alla frequenza di ciascun corso.</p>
|
||||
<label class="form-label fw-semibold">Nuova data di completamento</label>
|
||||
<input type="date" id="renewDate" class="form-control" value="<?= date('Y-m-d') ?>" required>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light border" data-bs-dismiss="modal">Annulla</button>
|
||||
<button type="submit" class="btn btn-warning" id="renewSaveBtn">Aggiorna</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var renewModal = new bootstrap.Modal(document.getElementById('bulkRenewModal'));
|
||||
var checkAll = document.getElementById('checkAll');
|
||||
|
||||
function checkedIds() {
|
||||
return Array.prototype.slice.call(document.querySelectorAll('.row-check:checked'))
|
||||
.map(function(c) { return c.value; });
|
||||
}
|
||||
function refreshBulkBar() {
|
||||
var ids = checkedIds();
|
||||
var bar = document.getElementById('bulkBar');
|
||||
document.getElementById('bulkSelCount').textContent = ids.length;
|
||||
if (ids.length > 0) { bar.classList.remove('d-none'); bar.classList.add('d-flex'); }
|
||||
else { bar.classList.add('d-none'); bar.classList.remove('d-flex'); }
|
||||
var all = document.querySelectorAll('.row-check');
|
||||
if (checkAll) checkAll.checked = (all.length > 0 && ids.length === all.length);
|
||||
}
|
||||
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target && e.target.classList && e.target.classList.contains('row-check')) refreshBulkBar();
|
||||
});
|
||||
if (checkAll) checkAll.addEventListener('change', function() {
|
||||
document.querySelectorAll('.row-check').forEach(function(c) { c.checked = checkAll.checked; });
|
||||
refreshBulkBar();
|
||||
});
|
||||
document.getElementById('btnBulkDeselect').addEventListener('click', function() {
|
||||
document.querySelectorAll('.row-check').forEach(function(c) { c.checked = false; });
|
||||
if (checkAll) checkAll.checked = false;
|
||||
refreshBulkBar();
|
||||
});
|
||||
|
||||
document.getElementById('btnBulkRenew').addEventListener('click', function() {
|
||||
var ids = checkedIds();
|
||||
if (ids.length === 0) return;
|
||||
document.getElementById('renewCount').textContent = ids.length;
|
||||
renewModal.show();
|
||||
});
|
||||
|
||||
document.getElementById('bulkRenewForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var ids = checkedIds();
|
||||
var date = document.getElementById('renewDate').value;
|
||||
if (ids.length === 0) { renewModal.hide(); return; }
|
||||
if (!date) { Swal.fire('Attenzione', 'Indicare la data.', 'warning'); return; }
|
||||
|
||||
var btn = document.getElementById('renewSaveBtn');
|
||||
btn.disabled = true;
|
||||
var orig = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Salvataggio...';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('completed_date', date);
|
||||
ids.forEach(function(id) { fd.append('training_ids[]', id); });
|
||||
|
||||
fetch('ajax/trainings/bulk_update_deadline.php', { method: 'POST', body: fd })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
renewModal.hide();
|
||||
Swal.fire({ icon: 'success', title: 'Fatto', text: data.message, timer: 1800, showConfirmButton: false })
|
||||
.then(function() { location.reload(); });
|
||||
} else {
|
||||
btn.disabled = false; btn.innerHTML = orig;
|
||||
Swal.fire('Errore', data.message || 'Errore.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btn.disabled = false; btn.innerHTML = orig;
|
||||
Swal.fire('Errore', 'Errore di connessione.', 'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user