maintaince module
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Create/edit modal for a registry item.
|
||||
* Expects $formData (mnt_form_data) in scope. Defines mntOpenEquipmentModal(data).
|
||||
*/
|
||||
|
||||
$statuses = $statuses ?? mnt_equipment_statuses();
|
||||
?>
|
||||
<div class="modal fade" id="equipmentModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-fullscreen-sm-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="equipmentModalTitle">Nuova attrezzatura</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||||
</div>
|
||||
<form id="equipmentForm">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="id" id="eqId" value="">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Nome <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="name" id="eqName" maxlength="100" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Categoria <span class="text-danger">*</span></label>
|
||||
<select class="form-select" name="category_id" id="eqCategory" required>
|
||||
<option value="">— seleziona —</option>
|
||||
<?php foreach ($formData['categories'] as $category): ?>
|
||||
<option value="<?= (int)$category['id'] ?>" data-requires-line="<?= (int)$category['requires_line'] ?>">
|
||||
<?= mnt_h($category['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6" id="eqLineWrap" style="display:none">
|
||||
<label class="form-label fw-semibold">Linea di produzione <span class="text-danger">*</span></label>
|
||||
<select class="form-select" name="line_id" id="eqLine">
|
||||
<option value="">— seleziona —</option>
|
||||
<?php foreach ($formData['lines'] as $line): ?>
|
||||
<option value="<?= (int)$line['id'] ?>">
|
||||
Linea <?= (int)$line['line_number'] ?> — <?= mnt_h($line['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Matricola</label>
|
||||
<input type="text" class="form-control" name="registration_number" id="eqRegistration" maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Numero di serie</label>
|
||||
<input type="text" class="form-control" name="serial_number" id="eqSerial" maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Lotto / partita</label>
|
||||
<input type="text" class="form-control" name="batch_lot" id="eqBatch" maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Costruttore</label>
|
||||
<input type="text" class="form-control" name="manufacturer" id="eqManufacturer" maxlength="150">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Data di acquisto</label>
|
||||
<input type="date" class="form-control" name="purchase_date" id="eqPurchase">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Data di messa in servizio</label>
|
||||
<input type="date" class="form-control" name="commissioning_date" id="eqCommissioning">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Tipo</label>
|
||||
<input type="text" class="form-control" name="tool_type" id="eqType" maxlength="50">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Stato</label>
|
||||
<select class="form-select" name="status" id="eqStatus">
|
||||
<?php foreach ($statuses as $value => $label): ?>
|
||||
<option value="<?= mnt_h($value) ?>"><?= mnt_h($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Reparto</label>
|
||||
<select class="form-select" name="department_id" id="eqDepartment">
|
||||
<option value="">—</option>
|
||||
<?php foreach ($formData['departments'] as $department): ?>
|
||||
<option value="<?= (int)$department['id'] ?>"><?= mnt_h($department['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Ubicazione</label>
|
||||
<input type="text" class="form-control" name="location" id="eqLocation" maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Descrizione</label>
|
||||
<textarea class="form-control" name="description" id="eqDescription" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Note</label>
|
||||
<textarea class="form-control" name="notes" id="eqNotes" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annulla</button>
|
||||
<button type="submit" class="btn btn-mnt-primary">Salva</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Shows the line picker only for categories flagged requires_line
|
||||
function mntSyncLineVisibility() {
|
||||
const requiresLine = $('#eqCategory option:selected').data('requires-line') == 1;
|
||||
$('#eqLineWrap').toggle(requiresLine);
|
||||
$('#eqLine').prop('required', requiresLine);
|
||||
if (!requiresLine) { $('#eqLine').val(''); }
|
||||
}
|
||||
|
||||
function mntOpenEquipmentModal(data) {
|
||||
const isEdit = !!data;
|
||||
$('#equipmentModalTitle').text(isEdit ? 'Modifica attrezzatura' : 'Nuova attrezzatura');
|
||||
|
||||
$('#eqId').val(isEdit ? data.id : '');
|
||||
$('#eqName').val(isEdit ? (data.name || '') : '');
|
||||
$('#eqCategory').val(isEdit && data.category_id ? String(data.category_id) : '');
|
||||
$('#eqLine').val(isEdit && data.line_id ? String(data.line_id) : '');
|
||||
$('#eqRegistration').val(isEdit ? (data.registration_number || '') : '');
|
||||
$('#eqSerial').val(isEdit ? (data.serial_number || '') : '');
|
||||
$('#eqBatch').val(isEdit ? (data.batch_lot || '') : '');
|
||||
$('#eqManufacturer').val(isEdit ? (data.manufacturer || '') : '');
|
||||
$('#eqPurchase').val(isEdit ? (data.purchase_date || '') : '');
|
||||
$('#eqCommissioning').val(isEdit ? (data.commissioning_date || '') : '');
|
||||
$('#eqType').val(isEdit ? (data.tool_type || '') : '');
|
||||
$('#eqStatus').val(isEdit ? (data.status || 'active') : 'active');
|
||||
$('#eqDepartment').val(isEdit && data.department_id ? String(data.department_id) : '');
|
||||
$('#eqLocation').val(isEdit ? (data.location || '') : '');
|
||||
$('#eqDescription').val(isEdit ? (data.description || '') : '');
|
||||
$('#eqNotes').val(isEdit ? (data.notes || '') : '');
|
||||
|
||||
mntSyncLineVisibility();
|
||||
new bootstrap.Modal('#equipmentModal').show();
|
||||
}
|
||||
|
||||
$(function () {
|
||||
$('#eqCategory').on('change', mntSyncLineVisibility);
|
||||
|
||||
$('#equipmentForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const name = $('#eqName').val().trim();
|
||||
if (!name) { Swal.fire({ icon: 'warning', title: 'Il nome è obbligatorio' }); return; }
|
||||
if (!$('#eqCategory').val()) { Swal.fire({ icon: 'warning', title: 'Seleziona una categoria' }); return; }
|
||||
if ($('#eqLineWrap').is(':visible') && !$('#eqLine').val()) {
|
||||
Swal.fire({ icon: 'warning', title: 'Seleziona la linea di produzione' });
|
||||
return;
|
||||
}
|
||||
|
||||
$.post('manutenzioni/ajax/save_equipment.php', $(this).serialize())
|
||||
.done(res => {
|
||||
if (res.success) {
|
||||
if (window.mntAfterEquipmentSave) { window.mntAfterEquipmentSave(res); }
|
||||
else { location.reload(); }
|
||||
} else {
|
||||
Swal.fire({ icon: 'error', title: 'Errore', text: res.message });
|
||||
}
|
||||
})
|
||||
.fail(() => Swal.fire({ icon: 'error', title: 'Errore di rete' }));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,483 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Manutenzioni — shared helpers.
|
||||
*
|
||||
* Included by every page and ajax endpoint of the module. Assumes a PDO
|
||||
* connection is available through DBHandlerSelect (class/db-functions.php).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../class/db-functions.php';
|
||||
|
||||
if (!function_exists('mnt_pdo')) {
|
||||
function mnt_pdo(): PDO
|
||||
{
|
||||
return DBHandlerSelect::getInstance()->getConnection();
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('mnt_h')) {
|
||||
function mnt_h($value): string
|
||||
{
|
||||
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal HTML email body, same visual language as the scadenzario mails.
|
||||
*
|
||||
* Lives here rather than in cron/send_notifications.php so that the preview
|
||||
* (make mail-preview-html) renders exactly what the cron sends, instead of a
|
||||
* copy that drifts.
|
||||
*/
|
||||
if (!function_exists('mnt_mail_body')) {
|
||||
function mnt_mail_body(string $heading, string $subject, string $message, string $color, string $url): string
|
||||
{
|
||||
$safeHeading = htmlspecialchars($heading, ENT_QUOTES, 'UTF-8');
|
||||
$safeSubject = htmlspecialchars($subject, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return '<!doctype html><html><body style="margin:0;padding:24px;background:#f4f6f8;font-family:Arial,Helvetica,sans-serif;color:#1e3a44">'
|
||||
. '<div style="max-width:560px;margin:0 auto;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 2px 10px rgba(0,0,0,.06)">'
|
||||
. '<div style="background:' . $color . ';color:#fff;padding:16px 20px;font-size:17px;font-weight:bold">' . $safeHeading . '</div>'
|
||||
. '<div style="padding:20px">'
|
||||
. '<p style="font-size:15px;font-weight:bold;margin:0 0 10px">' . $safeSubject . '</p>'
|
||||
. '<p style="font-size:14px;line-height:1.55;margin:0 0 18px">' . $message . '</p>'
|
||||
. '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" style="display:inline-block;background:#2f7d8f;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;font-size:14px">Apri la scheda</a>'
|
||||
. '</div></div></body></html>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subject and body of a notification, for both kinds.
|
||||
*
|
||||
* @param array $maintenance needs equipment_name, next_due_date, is_critical
|
||||
* @return array{subject:string, body:string}
|
||||
*/
|
||||
if (!function_exists('mnt_notification_mail')) {
|
||||
function mnt_notification_mail(array $maintenance, string $label, int $daysLeft, string $detailUrl, ?string $today = null): array
|
||||
{
|
||||
$today = $today ?? date('Y-m-d');
|
||||
$isOverdue = $maintenance['next_due_date'] < $today;
|
||||
$due = date('d/m/Y', strtotime($maintenance['next_due_date']));
|
||||
|
||||
$criticalNote = (int)($maintenance['is_critical'] ?? 0) === 1
|
||||
? '<br><strong style="color:#b02a37">Manutenzione critica.</strong>'
|
||||
: '';
|
||||
|
||||
if ($isOverdue) {
|
||||
return [
|
||||
'subject' => '⚠️ Manutenzione scaduta: ' . $maintenance['equipment_name'],
|
||||
'body' => mnt_mail_body(
|
||||
'Manutenzione scaduta',
|
||||
$maintenance['equipment_name'] . ' — ' . $label,
|
||||
'Era prevista per il <strong>' . $due . '</strong>, in ritardo di <strong>'
|
||||
. abs($daysLeft) . ' giorni</strong>.' . $criticalNote,
|
||||
'#dc3545',
|
||||
$detailUrl
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
$daysText = $daysLeft === 0 ? '<strong>oggi</strong>' : 'tra <strong>' . $daysLeft . ' giorni</strong>';
|
||||
|
||||
return [
|
||||
'subject' => '🔧 Manutenzione in scadenza: ' . $maintenance['equipment_name'],
|
||||
'body' => mnt_mail_body(
|
||||
'Manutenzione in scadenza',
|
||||
$maintenance['equipment_name'] . ' — ' . $label,
|
||||
'È prevista per il <strong>' . $due . '</strong> (' . $daysText . ').' . $criticalNote,
|
||||
'#2f7d8f',
|
||||
$detailUrl
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads ?page and ?per_page for the paged lists.
|
||||
*
|
||||
* per_page is a whitelist, not a free number: it lands in a LIMIT clause and
|
||||
* an open value would let anyone ask for the whole table in one request.
|
||||
* Returns [page, perPage] with page still unbounded above — the caller clamps
|
||||
* it once it knows how many rows the filters actually match.
|
||||
*/
|
||||
if (!function_exists('mnt_page_params')) {
|
||||
function mnt_page_params(int $default = 25): array
|
||||
{
|
||||
$allowed = [25, 50, 100];
|
||||
$perPage = isset($_GET['per_page']) ? (int)$_GET['per_page'] : $default;
|
||||
if (!in_array($perPage, $allowed, true)) {
|
||||
$perPage = $default;
|
||||
}
|
||||
|
||||
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||
|
||||
return [max(1, $page), $perPage];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequency units offered in the maintenance form.
|
||||
* 'on_demand' = no automatic due date (the client's "al bisogno" cases).
|
||||
*/
|
||||
if (!function_exists('mnt_frequency_units')) {
|
||||
function mnt_frequency_units(): array
|
||||
{
|
||||
return [
|
||||
'day' => 'Giorni',
|
||||
'week' => 'Settimane',
|
||||
'month' => 'Mesi',
|
||||
'year' => 'Anni',
|
||||
'on_demand' => 'Al bisogno (senza scadenza automatica)',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('mnt_intervention_types')) {
|
||||
function mnt_intervention_types(): array
|
||||
{
|
||||
return ['scheduled' => 'Programmata', 'extraordinary' => 'Straordinaria'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('mnt_execution_types')) {
|
||||
function mnt_execution_types(): array
|
||||
{
|
||||
return ['internal' => 'Interna', 'external' => 'Esterna'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('mnt_equipment_statuses')) {
|
||||
function mnt_equipment_statuses(): array
|
||||
{
|
||||
return [
|
||||
'active' => 'Attivo',
|
||||
'out_of_service' => 'Fuori servizio',
|
||||
'decommissioned' => 'Dismesso',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options managed through the existing lookup_values.php CRUD.
|
||||
* Falls back to sensible defaults if the category has no rows yet.
|
||||
*/
|
||||
if (!function_exists('mnt_lookup')) {
|
||||
function mnt_lookup(PDO $pdo, string $category, array $fallback = []): array
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
if (!isset($cache[$category])) {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT value, label
|
||||
FROM ws_lookup_options
|
||||
WHERE category = ? AND is_active = 1
|
||||
ORDER BY sort_order ASC, label ASC
|
||||
");
|
||||
$stmt->execute([$category]);
|
||||
|
||||
$options = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$options[$row['value']] = $row['label'];
|
||||
}
|
||||
|
||||
$cache[$category] = $options ?: $fallback;
|
||||
}
|
||||
|
||||
return $cache[$category];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Next due date = last completed intervention + frequency.
|
||||
* Returns null when the maintenance has no automatic schedule or was never done.
|
||||
*/
|
||||
if (!function_exists('mnt_calc_next_due')) {
|
||||
function mnt_calc_next_due(?string $lastDoneDate, ?int $frequencyValue, string $frequencyUnit): ?string
|
||||
{
|
||||
if ($lastDoneDate === null || $frequencyUnit === 'on_demand' || !$frequencyValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$map = ['day' => 'day', 'week' => 'week', 'month' => 'month', 'year' => 'year'];
|
||||
if (!isset($map[$frequencyUnit])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = date_create($lastDoneDate);
|
||||
if (!$date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dayOfMonth = (int)$date->format('j');
|
||||
$date->modify('+' . $frequencyValue . ' ' . $map[$frequencyUnit]);
|
||||
|
||||
// PHP fa traboccare i mesi corti: 31/01 +1 mese diventa 03/03.
|
||||
// Per una manutenzione la data attesa è l'ultimo giorno del mese.
|
||||
if (in_array($frequencyUnit, ['month', 'year'], true) && (int)$date->format('j') !== $dayOfMonth) {
|
||||
$date->modify('last day of previous month');
|
||||
}
|
||||
|
||||
return $date->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute last_done_date / next_due_date of a maintenance from its
|
||||
* completed interventions. Call after any intervention insert/update/delete.
|
||||
*/
|
||||
if (!function_exists('mnt_recalc_maintenance')) {
|
||||
function mnt_recalc_maintenance(PDO $pdo, int $maintenanceId): void
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT frequency_value, frequency_unit FROM maint_maintenances WHERE id = ?");
|
||||
$stmt->execute([$maintenanceId]);
|
||||
$maintenance = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$maintenance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only completed interventions move the schedule; planned/in-progress do not.
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT MAX(performed_at)
|
||||
FROM maint_interventions
|
||||
WHERE maintenance_id = ? AND status = 'completed'
|
||||
");
|
||||
$stmt->execute([$maintenanceId]);
|
||||
$lastDone = $stmt->fetchColumn() ?: null;
|
||||
|
||||
$nextDue = mnt_calc_next_due(
|
||||
$lastDone,
|
||||
$maintenance['frequency_value'] !== null ? (int)$maintenance['frequency_value'] : null,
|
||||
(string)$maintenance['frequency_unit']
|
||||
);
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE maint_maintenances SET last_done_date = ?, next_due_date = ? WHERE id = ?");
|
||||
$stmt->execute([$lastDone, $nextDue, $maintenanceId]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule state of a maintenance, used for badges and filters.
|
||||
* Returns one of: overdue | due_soon | ok | none
|
||||
*/
|
||||
if (!function_exists('mnt_due_state')) {
|
||||
function mnt_due_state(?string $nextDueDate, int $alertDays = 0, ?string $today = null): string
|
||||
{
|
||||
if (empty($nextDueDate)) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
$today = $today ?: date('Y-m-d');
|
||||
|
||||
if ($nextDueDate < $today) {
|
||||
return 'overdue';
|
||||
}
|
||||
|
||||
$threshold = date('Y-m-d', strtotime($today . ' +' . max(0, $alertDays) . ' days'));
|
||||
|
||||
return $nextDueDate <= $threshold ? 'due_soon' : 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('mnt_due_badge')) {
|
||||
/**
|
||||
* Lo stato "none" copre due casi diversi che non vanno confusi:
|
||||
* una manutenzione al bisogno (per scelta senza scadenza) e una
|
||||
* programmata mai eseguita (la scadenza arriverà al primo intervento).
|
||||
*/
|
||||
function mnt_due_badge(string $state, ?string $frequencyUnit = null): array
|
||||
{
|
||||
switch ($state) {
|
||||
case 'overdue':
|
||||
return ['label' => 'Scaduta', 'class' => 'mnt-badge-overdue'];
|
||||
case 'due_soon':
|
||||
return ['label' => 'In scadenza', 'class' => 'mnt-badge-soon'];
|
||||
case 'ok':
|
||||
return ['label' => 'In regola', 'class' => 'mnt-badge-ok'];
|
||||
default:
|
||||
if ($frequencyUnit !== null && $frequencyUnit !== 'on_demand') {
|
||||
return ['label' => 'Mai eseguita', 'class' => 'mnt-badge-none'];
|
||||
}
|
||||
|
||||
return ['label' => 'Al bisogno', 'class' => 'mnt-badge-none'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('mnt_format_date')) {
|
||||
function mnt_format_date(?string $date): string
|
||||
{
|
||||
if (empty($date) || $date === '0000-00-00') {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$ts = strtotime($date);
|
||||
|
||||
return $ts ? date('d/m/Y', $ts) : '—';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable frequency ("Ogni 3 mesi", "Al bisogno").
|
||||
*/
|
||||
if (!function_exists('mnt_format_frequency')) {
|
||||
function mnt_format_frequency(?int $value, string $unit, ?string $note = null): string
|
||||
{
|
||||
$units = [
|
||||
'day' => ['giorno', 'giorni'],
|
||||
'week' => ['settimana', 'settimane'],
|
||||
'month' => ['mese', 'mesi'],
|
||||
'year' => ['anno', 'anni'],
|
||||
];
|
||||
|
||||
if ($unit === 'on_demand' || !$value || !isset($units[$unit])) {
|
||||
$text = 'Al bisogno';
|
||||
} else {
|
||||
$word = $value === 1 ? $units[$unit][0] : $units[$unit][1];
|
||||
$text = 'Ogni ' . $value . ' ' . $word;
|
||||
}
|
||||
|
||||
if (!empty($note)) {
|
||||
$text .= ' — ' . $note;
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit log entry. All ids are optional so the same call works for
|
||||
* equipment, maintenance and intervention events.
|
||||
*/
|
||||
if (!function_exists('mnt_log')) {
|
||||
function mnt_log(
|
||||
PDO $pdo,
|
||||
string $action,
|
||||
?int $equipmentId = null,
|
||||
?int $maintenanceId = null,
|
||||
?int $interventionId = null,
|
||||
?int $userId = null,
|
||||
?array $changes = null,
|
||||
?string $notes = null
|
||||
): void {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO maint_histories
|
||||
(equipment_id, maintenance_id, intervention_id, user_id, action, changes, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$equipmentId,
|
||||
$maintenanceId,
|
||||
$interventionId,
|
||||
$userId,
|
||||
$action,
|
||||
$changes !== null ? json_encode($changes, JSON_UNESCAPED_UNICODE) : null,
|
||||
$notes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store one uploaded file into attachments/ and return its metadata.
|
||||
* Throws RuntimeException on a rejected file.
|
||||
*/
|
||||
if (!function_exists('mnt_store_upload')) {
|
||||
function mnt_store_upload(array $file, string $prefix = 'mnt'): array
|
||||
{
|
||||
$allowedExtensions = [
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'odt', 'ods',
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'bmp', 'zip',
|
||||
];
|
||||
$maxBytes = 20 * 1024 * 1024;
|
||||
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new RuntimeException('Errore durante il caricamento del file.');
|
||||
}
|
||||
|
||||
if (($file['size'] ?? 0) > $maxBytes) {
|
||||
throw new RuntimeException('File troppo grande (massimo 20 MB).');
|
||||
}
|
||||
|
||||
$originalName = (string)($file['name'] ?? 'file');
|
||||
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
|
||||
if (!in_array($extension, $allowedExtensions, true)) {
|
||||
throw new RuntimeException('Tipo di file non consentito: .' . $extension);
|
||||
}
|
||||
|
||||
$uploadDir = __DIR__ . '/../attachments/';
|
||||
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) {
|
||||
throw new RuntimeException('Cartella allegati non scrivibile.');
|
||||
}
|
||||
|
||||
$storedName = uniqid($prefix . '_', true) . '.' . $extension;
|
||||
$safeStored = preg_replace('/[^A-Za-z0-9._-]/', '_', $storedName);
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $uploadDir . $safeStored)) {
|
||||
throw new RuntimeException('Impossibile salvare il file.');
|
||||
}
|
||||
|
||||
return [
|
||||
'original_name' => $originalName,
|
||||
'stored_name' => $safeStored,
|
||||
'mime_type' => $file['type'] ?? null,
|
||||
'size' => (int)($file['size'] ?? 0),
|
||||
'is_image' => in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'], true),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('mnt_delete_stored_file')) {
|
||||
function mnt_delete_stored_file(string $storedName): void
|
||||
{
|
||||
$path = __DIR__ . '/../attachments/' . basename($storedName);
|
||||
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Employees / suppliers / departments pickers, shared by several forms.
|
||||
*/
|
||||
if (!function_exists('mnt_form_data')) {
|
||||
function mnt_form_data(PDO $pdo): array
|
||||
{
|
||||
return [
|
||||
'categories' => $pdo->query("
|
||||
SELECT id, name, color, requires_line
|
||||
FROM inv_categories
|
||||
WHERE is_active = 1
|
||||
ORDER BY sort_order ASC, name ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC),
|
||||
|
||||
'lines' => $pdo->query("
|
||||
SELECT id, line_number, name, color
|
||||
FROM production_lines
|
||||
ORDER BY line_number ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC),
|
||||
|
||||
'departments' => $pdo->query("
|
||||
SELECT id, name
|
||||
FROM departments
|
||||
WHERE is_active = 1
|
||||
ORDER BY sort_order ASC, name ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC),
|
||||
|
||||
'employees' => $pdo->query("
|
||||
SELECT id, CONCAT(first_name, ' ', last_name) AS full_name
|
||||
FROM employees
|
||||
WHERE status = 'active'
|
||||
ORDER BY first_name ASC, last_name ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC),
|
||||
|
||||
'suppliers' => $pdo->query("
|
||||
SELECT idsupplier AS id, supplier_name AS name
|
||||
FROM suppliers
|
||||
ORDER BY supplier_name ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Register/edit one intervention.
|
||||
* Expects $formData and $pdo in scope. Defines mntOpenInterventionModal(data, maintenance).
|
||||
*
|
||||
* The signature is drawn on a canvas (finger on tablet, mouse on PC) and
|
||||
* posted as a PNG data URL.
|
||||
*/
|
||||
|
||||
$statusOptions = mnt_lookup($pdo, 'maint_status', [
|
||||
'planned' => 'Pianificato',
|
||||
'in_progress' => 'In corso',
|
||||
'completed' => 'Completato',
|
||||
]);
|
||||
|
||||
$resultOptions = mnt_lookup($pdo, 'maint_result', [
|
||||
'compliant' => 'Conforme',
|
||||
'not_compliant' => 'Non conforme',
|
||||
'blocked' => 'Bloccato',
|
||||
]);
|
||||
?>
|
||||
<div class="modal fade" id="interventionModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-fullscreen-sm-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="interventionModalTitle">Registra intervento</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||||
</div>
|
||||
<form id="interventionForm">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="id" id="ivId" value="">
|
||||
<input type="hidden" name="maintenance_id" id="ivMaintenanceId" value="">
|
||||
<input type="hidden" name="signature" id="ivSignature" value="">
|
||||
<input type="hidden" name="clear_signature" id="ivClearSignature" value="">
|
||||
|
||||
<div class="alert alert-light border small mb-3" id="ivContext"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label fw-semibold">Data <span class="text-danger">*</span></label>
|
||||
<input type="date" class="form-control" name="performed_at" id="ivDate" required>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-4">
|
||||
<label class="form-label fw-semibold">Stato</label>
|
||||
<select class="form-select" name="status" id="ivStatus">
|
||||
<?php foreach ($statusOptions as $value => $label): ?>
|
||||
<option value="<?= mnt_h($value) ?>" <?= $value === 'completed' ? 'selected' : '' ?>>
|
||||
<?= mnt_h($label) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-4">
|
||||
<label class="form-label fw-semibold">Esito</label>
|
||||
<select class="form-select" name="result" id="ivResult">
|
||||
<option value="">—</option>
|
||||
<?php foreach ($resultOptions as $value => $label): ?>
|
||||
<option value="<?= mnt_h($value) ?>"><?= mnt_h($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Operatore</label>
|
||||
<select class="form-select" name="operator_employee_id" id="ivOperator">
|
||||
<option value="">—</option>
|
||||
<?php foreach ($formData['employees'] as $employee): ?>
|
||||
<option value="<?= (int)$employee['id'] ?>"><?= mnt_h($employee['full_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Operatore esterno</label>
|
||||
<input type="text" class="form-control" name="operator_name" id="ivOperatorName" maxlength="191"
|
||||
placeholder="Nome tecnico non presente in anagrafica">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Fornitore</label>
|
||||
<select class="form-select" name="supplier_id" id="ivSupplier">
|
||||
<option value="">—</option>
|
||||
<?php foreach ($formData['suppliers'] as $supplier): ?>
|
||||
<option value="<?= (int)$supplier['id'] ?>"><?= mnt_h($supplier['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Note sull'intervento</label>
|
||||
<textarea class="form-control" name="notes" id="ivNotes" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Materiali / parti sostituite</label>
|
||||
<textarea class="form-control" name="materials" id="ivMaterials" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Firma operatore</label>
|
||||
<div class="mnt-signature-wrap">
|
||||
<canvas id="ivSignatureCanvas"></canvas>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||
<span class="form-text mb-0">Firma con il dito su tablet o con il mouse.</span>
|
||||
<button type="button" class="btn btn-sm btn-light" id="ivSignatureClear">Cancella firma</button>
|
||||
</div>
|
||||
<div id="ivExistingSignature" class="mt-2" style="display:none">
|
||||
<span class="mnt-section-title">Firma registrata</span>
|
||||
<img id="ivExistingSignatureImg" alt="Firma" style="max-height:90px;border:1px solid #e6eef0;border-radius:.4rem;background:#fff">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Allegati (modulo del fornitore, foto)</label>
|
||||
<div id="ivFilesList" class="mb-2"></div>
|
||||
<input type="file" class="form-control" id="ivFilesInput" multiple>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annulla</button>
|
||||
<button type="submit" class="btn btn-mnt-primary">Salva intervento</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
let canvas, ctx, drawing = false, hasStrokes = false;
|
||||
|
||||
function resizeCanvas() {
|
||||
if (!canvas) return;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// Preserve what is already drawn across a resize
|
||||
const snapshot = hasStrokes ? canvas.toDataURL() : null;
|
||||
|
||||
canvas.width = rect.width * ratio;
|
||||
canvas.height = rect.height * ratio;
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = '#1e3a44';
|
||||
|
||||
if (snapshot) {
|
||||
const img = new Image();
|
||||
img.onload = () => ctx.drawImage(img, 0, 0, rect.width, rect.height);
|
||||
img.src = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
function pointerPos(event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const source = event.touches ? event.touches[0] : event;
|
||||
return { x: source.clientX - rect.left, y: source.clientY - rect.top };
|
||||
}
|
||||
|
||||
function startDraw(event) {
|
||||
event.preventDefault();
|
||||
drawing = true;
|
||||
hasStrokes = true;
|
||||
const p = pointerPos(event);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
}
|
||||
|
||||
function moveDraw(event) {
|
||||
if (!drawing) return;
|
||||
event.preventDefault();
|
||||
const p = pointerPos(event);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function endDraw() { drawing = false; }
|
||||
|
||||
function clearCanvas() {
|
||||
if (!ctx) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
hasStrokes = false;
|
||||
}
|
||||
|
||||
window.mntOpenInterventionModal = function (data, maintenance) {
|
||||
const isEdit = !!data;
|
||||
|
||||
$('#interventionModalTitle').text(isEdit ? 'Modifica intervento' : 'Registra intervento');
|
||||
$('#ivContext').html(
|
||||
'<strong>' + $('<div>').text(maintenance.equipment_name || '').html() + '</strong> — ' +
|
||||
$('<div>').text(maintenance.title || '').html()
|
||||
);
|
||||
|
||||
$('#ivId').val(isEdit ? data.id : '');
|
||||
$('#ivMaintenanceId').val(maintenance.id);
|
||||
$('#ivDate').val(isEdit ? data.performed_at : new Date().toISOString().slice(0, 10));
|
||||
$('#ivStatus').val(isEdit ? data.status : 'completed');
|
||||
$('#ivResult').val(isEdit ? (data.result || '') : '');
|
||||
$('#ivOperator').val(isEdit && data.operator_employee_id ? String(data.operator_employee_id) : '');
|
||||
$('#ivOperatorName').val(isEdit ? (data.operator_name || '') : '');
|
||||
$('#ivSupplier').val(isEdit && data.supplier_id ? String(data.supplier_id) : (maintenance.supplier_id || ''));
|
||||
$('#ivNotes').val(isEdit ? (data.notes || '') : '');
|
||||
$('#ivMaterials').val(isEdit ? (data.materials || '') : '');
|
||||
$('#ivSignature').val('');
|
||||
$('#ivClearSignature').val('');
|
||||
$('#ivFilesInput').val('');
|
||||
$('#ivFilesList').empty();
|
||||
|
||||
if (isEdit && data.signature_path) {
|
||||
$('#ivExistingSignatureImg').attr('src', 'manutenzioni/ajax/download_file.php?scope=signature&id=' + data.id);
|
||||
$('#ivExistingSignature').show();
|
||||
} else {
|
||||
$('#ivExistingSignature').hide();
|
||||
}
|
||||
|
||||
if (isEdit && data.files) {
|
||||
data.files.forEach(f => {
|
||||
$('#ivFilesList').append(
|
||||
$('<div class="mnt-file-row"></div>').append(
|
||||
$('<i class="fa-solid fa-paperclip text-muted"></i>'),
|
||||
$('<a class="file-name" target="_blank"></a>')
|
||||
.attr('href', 'manutenzioni/ajax/download_file.php?scope=intervention&id=' + f.id)
|
||||
.text(f.original_name),
|
||||
$('<button type="button" class="btn-action btn-action-delete btn-del-iv-file"><i class="fa-solid fa-trash"></i></button>')
|
||||
.attr('data-id', f.id)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const modal = new bootstrap.Modal('#interventionModal');
|
||||
modal.show();
|
||||
|
||||
$('#interventionModal').one('shown.bs.modal', function () {
|
||||
clearCanvas();
|
||||
resizeCanvas();
|
||||
});
|
||||
};
|
||||
|
||||
$(function () {
|
||||
canvas = document.getElementById('ivSignatureCanvas');
|
||||
if (!canvas) return;
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.addEventListener('mousedown', startDraw);
|
||||
canvas.addEventListener('mousemove', moveDraw);
|
||||
window.addEventListener('mouseup', endDraw);
|
||||
canvas.addEventListener('touchstart', startDraw, { passive: false });
|
||||
canvas.addEventListener('touchmove', moveDraw, { passive: false });
|
||||
canvas.addEventListener('touchend', endDraw);
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
$('#ivSignatureClear').on('click', function () {
|
||||
clearCanvas();
|
||||
// Also drop a signature stored earlier
|
||||
if ($('#ivExistingSignature').is(':visible')) {
|
||||
$('#ivClearSignature').val('1');
|
||||
$('#ivExistingSignature').hide();
|
||||
}
|
||||
});
|
||||
|
||||
$('#ivFilesList').on('click', '.btn-del-iv-file', function () {
|
||||
const $row = $(this).closest('.mnt-file-row');
|
||||
$.post('manutenzioni/ajax/delete_file.php', { scope: 'intervention', id: $(this).data('id') })
|
||||
.done(res => {
|
||||
if (res.success) { $row.remove(); }
|
||||
else { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); }
|
||||
});
|
||||
});
|
||||
|
||||
$('#interventionForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!$('#ivDate').val()) {
|
||||
Swal.fire({ icon: 'warning', title: 'Indica la data dell\'intervento' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasStrokes) { $('#ivSignature').val(canvas.toDataURL('image/png')); }
|
||||
|
||||
$.post('manutenzioni/ajax/save_intervention.php', $(this).serialize())
|
||||
.done(res => {
|
||||
if (!res.success) {
|
||||
Swal.fire({ icon: 'error', title: 'Errore', text: res.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.getElementById('ivFilesInput');
|
||||
if (!input.files.length) { location.reload(); return; }
|
||||
|
||||
const form = new FormData();
|
||||
form.append('scope', 'intervention');
|
||||
form.append('owner_id', res.id);
|
||||
Array.from(input.files).forEach(f => form.append('files[]', f));
|
||||
|
||||
$.ajax({
|
||||
url: 'manutenzioni/ajax/upload_file.php',
|
||||
method: 'POST', data: form, processData: false, contentType: false, dataType: 'json'
|
||||
}).always(() => location.reload());
|
||||
})
|
||||
.fail(() => Swal.fire({ icon: 'error', title: 'Errore di rete' }));
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Create/edit modal for a maintenance.
|
||||
* Expects $formData in scope. Defines mntOpenMaintenanceModal(data, equipmentId).
|
||||
*/
|
||||
?>
|
||||
<div class="modal fade" id="maintenanceModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-fullscreen-sm-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="maintenanceModalTitle">Nuova manutenzione</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||||
</div>
|
||||
<form id="maintenanceForm">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="id" id="mtId" value="">
|
||||
<input type="hidden" name="equipment_id" id="mtEquipmentId" value="">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-4 col-md-2">
|
||||
<label class="form-label fw-semibold">Codice</label>
|
||||
<input type="text" class="form-control" name="code" id="mtCode" maxlength="10" placeholder="A">
|
||||
</div>
|
||||
|
||||
<div class="col-8 col-md-10">
|
||||
<label class="form-label fw-semibold">Titolo <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="title" id="mtTitle" maxlength="500" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Descrizione dell'intervento</label>
|
||||
<textarea class="form-control" name="description" id="mtDescription" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Tipo</label>
|
||||
<select class="form-select" name="intervention_type" id="mtType">
|
||||
<?php foreach (mnt_intervention_types() as $value => $label): ?>
|
||||
<option value="<?= mnt_h($value) ?>"><?= mnt_h($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Esecuzione</label>
|
||||
<select class="form-select" name="execution_type" id="mtExecution">
|
||||
<?php foreach (mnt_execution_types() as $value => $label): ?>
|
||||
<option value="<?= mnt_h($value) ?>"><?= mnt_h($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12" id="mtSupplierWrap" style="display:none">
|
||||
<label class="form-label fw-semibold">Fornitore esterno <span class="text-danger">*</span></label>
|
||||
<select class="form-select" name="supplier_id" id="mtSupplier">
|
||||
<option value="">— seleziona —</option>
|
||||
<?php foreach ($formData['suppliers'] as $supplier): ?>
|
||||
<option value="<?= (int)$supplier['id'] ?>"><?= mnt_h($supplier['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="mnt-section-title mb-0 mt-2">Frequenza</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label fw-semibold">Ogni</label>
|
||||
<input type="number" class="form-control" name="frequency_value" id="mtFrequencyValue" min="1" max="999" value="1">
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-5">
|
||||
<label class="form-label fw-semibold">Unità</label>
|
||||
<select class="form-select" name="frequency_unit" id="mtFrequencyUnit">
|
||||
<?php foreach (mnt_frequency_units() as $value => $label): ?>
|
||||
<option value="<?= mnt_h($value) ?>" <?= $value === 'month' ? 'selected' : '' ?>><?= mnt_h($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label fw-semibold">Preavviso (giorni)</label>
|
||||
<input type="number" class="form-control" name="alert_days" id="mtAlertDays" min="0" max="365" value="7">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Nota sulla frequenza</label>
|
||||
<input type="text" class="form-control" name="frequency_note" id="mtFrequencyNote" maxlength="500"
|
||||
placeholder="Es. prima dell'accensione, d'estate settimanale, dopo lo spegnimento">
|
||||
<div class="form-text">Per i casi che non rientrano in un intervallo fisso.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Incaricato</label>
|
||||
<select class="form-select" name="assignee_employee_id" id="mtAssignee">
|
||||
<option value="">—</option>
|
||||
<?php foreach ($formData['employees'] as $employee): ?>
|
||||
<option value="<?= (int)$employee['id'] ?>"><?= mnt_h($employee['full_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Responsabile</label>
|
||||
<select class="form-select" name="supervisor_employee_id" id="mtSupervisor">
|
||||
<option value="">—</option>
|
||||
<?php foreach ($formData['employees'] as $employee): ?>
|
||||
<option value="<?= (int)$employee['id'] ?>"><?= mnt_h($employee['full_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Note</label>
|
||||
<textarea class="form-control" name="notes" id="mtNotes" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex flex-wrap gap-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_critical" id="mtCritical" value="1">
|
||||
<label class="form-check-label fw-semibold" for="mtCritical">Manutenzione critica</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<!-- Una checkbox non spuntata non viene inviata: senza questo campo
|
||||
nascosto (che deve precederla) l'endpoint riceverebbe sempre
|
||||
"attiva" e la manutenzione non si potrebbe mai disattivare. -->
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="mtActive" value="1" checked>
|
||||
<label class="form-check-label fw-semibold" for="mtActive">Attiva</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12" id="mtFilesWrap">
|
||||
<div class="mnt-section-title mt-2">Istruzioni allegate</div>
|
||||
<div id="mtFilesList" class="mb-2"></div>
|
||||
<input type="file" class="form-control" id="mtFilesInput" multiple>
|
||||
<div class="form-text">I file vengono caricati dopo il salvataggio.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annulla</button>
|
||||
<button type="submit" class="btn btn-mnt-primary">Salva</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function mntSyncMaintenanceForm() {
|
||||
const isExternal = $('#mtExecution').val() === 'external';
|
||||
$('#mtSupplierWrap').toggle(isExternal);
|
||||
|
||||
const onDemand = $('#mtFrequencyUnit').val() === 'on_demand';
|
||||
$('#mtFrequencyValue').prop('disabled', onDemand).closest('.col-6').toggle(!onDemand);
|
||||
$('#mtAlertDays').prop('disabled', onDemand);
|
||||
}
|
||||
|
||||
function mntRenderMaintenanceFiles(files) {
|
||||
const $list = $('#mtFilesList').empty();
|
||||
if (!files || !files.length) { return; }
|
||||
|
||||
files.forEach(f => {
|
||||
$list.append(
|
||||
$('<div class="mnt-file-row"></div>').append(
|
||||
$('<i class="fa-solid fa-paperclip text-muted"></i>'),
|
||||
$('<a class="file-name" target="_blank"></a>')
|
||||
.attr('href', 'manutenzioni/ajax/download_file.php?scope=maintenance&id=' + f.id)
|
||||
.text(f.original_name),
|
||||
$('<button type="button" class="btn-action btn-action-delete btn-del-file"><i class="fa-solid fa-trash"></i></button>')
|
||||
.attr('data-id', f.id)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function mntOpenMaintenanceModal(data, equipmentId) {
|
||||
const isEdit = !!data;
|
||||
$('#maintenanceModalTitle').text(isEdit ? 'Modifica manutenzione' : 'Nuova manutenzione');
|
||||
|
||||
$('#mtId').val(isEdit ? data.id : '');
|
||||
$('#mtEquipmentId').val(isEdit ? data.equipment_id : (equipmentId || ''));
|
||||
$('#mtCode').val(isEdit ? (data.code || '') : '');
|
||||
$('#mtTitle').val(isEdit ? (data.title || '') : '');
|
||||
$('#mtDescription').val(isEdit ? (data.description || '') : '');
|
||||
$('#mtType').val(isEdit ? data.intervention_type : 'scheduled');
|
||||
$('#mtExecution').val(isEdit ? data.execution_type : 'internal');
|
||||
$('#mtSupplier').val(isEdit && data.supplier_id ? String(data.supplier_id) : '');
|
||||
$('#mtFrequencyValue').val(isEdit && data.frequency_value ? data.frequency_value : 1);
|
||||
$('#mtFrequencyUnit').val(isEdit ? data.frequency_unit : 'month');
|
||||
$('#mtFrequencyNote').val(isEdit ? (data.frequency_note || '') : '');
|
||||
$('#mtAlertDays').val(isEdit ? data.alert_days : 7);
|
||||
$('#mtAssignee').val(isEdit && data.assignee_employee_id ? String(data.assignee_employee_id) : '');
|
||||
$('#mtSupervisor').val(isEdit && data.supervisor_employee_id ? String(data.supervisor_employee_id) : '');
|
||||
$('#mtNotes').val(isEdit ? (data.notes || '') : '');
|
||||
$('#mtCritical').prop('checked', isEdit ? Number(data.is_critical) === 1 : false);
|
||||
$('#mtActive').prop('checked', isEdit ? Number(data.is_active) === 1 : true);
|
||||
|
||||
mntRenderMaintenanceFiles(isEdit ? (data.files || []) : []);
|
||||
$('#mtFilesInput').val('');
|
||||
|
||||
mntSyncMaintenanceForm();
|
||||
new bootstrap.Modal('#maintenanceModal').show();
|
||||
}
|
||||
|
||||
$(function () {
|
||||
$('#mtExecution, #mtFrequencyUnit').on('change', mntSyncMaintenanceForm);
|
||||
|
||||
$('#mtFilesList').on('click', '.btn-del-file', function () {
|
||||
const $row = $(this).closest('.mnt-file-row');
|
||||
$.post('manutenzioni/ajax/delete_file.php', { scope: 'maintenance', id: $(this).data('id') })
|
||||
.done(res => {
|
||||
if (res.success) { $row.remove(); }
|
||||
else { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); }
|
||||
});
|
||||
});
|
||||
|
||||
$('#maintenanceForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!$('#mtTitle').val().trim()) {
|
||||
Swal.fire({ icon: 'warning', title: 'Il titolo è obbligatorio' });
|
||||
return;
|
||||
}
|
||||
if ($('#mtExecution').val() === 'external' && !$('#mtSupplier').val()) {
|
||||
Swal.fire({ icon: 'warning', title: 'Seleziona il fornitore esterno' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Disabled inputs are not serialised — send the unit explicitly
|
||||
const payload = $(this).serializeArray();
|
||||
payload.push({ name: 'frequency_unit', value: $('#mtFrequencyUnit').val() });
|
||||
|
||||
$.post('manutenzioni/ajax/save_maintenance.php', $.param(payload))
|
||||
.done(res => {
|
||||
if (!res.success) {
|
||||
Swal.fire({ icon: 'error', title: 'Errore', text: res.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.getElementById('mtFilesInput');
|
||||
if (!input.files.length) { location.reload(); return; }
|
||||
|
||||
const form = new FormData();
|
||||
form.append('scope', 'maintenance');
|
||||
form.append('owner_id', res.id);
|
||||
Array.from(input.files).forEach(f => form.append('files[]', f));
|
||||
|
||||
$.ajax({
|
||||
url: 'manutenzioni/ajax/upload_file.php',
|
||||
method: 'POST', data: form, processData: false, contentType: false, dataType: 'json'
|
||||
}).always(() => location.reload());
|
||||
})
|
||||
.fail(() => Swal.fire({ icon: 'error', title: 'Errore di rete' }));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shared <head> for the Manutenzioni pages.
|
||||
*
|
||||
* Usage (before including):
|
||||
* $MNT_TITLE = 'Registro attrezzature';
|
||||
* $MNT_PLUGINS = ['fullcalendar']; // opzionale
|
||||
* include __DIR__ . '/include/page_head.php';
|
||||
*
|
||||
* Works from any depth under /userarea/ — the base href is derived from
|
||||
* SCRIPT_NAME, so all asset paths stay relative to /userarea/.
|
||||
*/
|
||||
|
||||
$MNT_TITLE = $MNT_TITLE ?? 'Manutenzioni';
|
||||
$MNT_PLUGINS = $MNT_PLUGINS ?? [];
|
||||
|
||||
$mntScriptName = $_SERVER['SCRIPT_NAME'] ?? '';
|
||||
$mntMarker = '/userarea/';
|
||||
$mntPos = strpos($mntScriptName, $mntMarker);
|
||||
$MNT_BASE = $mntPos !== false
|
||||
? substr($mntScriptName, 0, $mntPos + strlen($mntMarker))
|
||||
: '/userarea/';
|
||||
?>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<base href="<?= mnt_h($MNT_BASE) ?>">
|
||||
<?php include(__DIR__ . '/../../cssinclude.php'); ?>
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<title><?= mnt_h($MNT_TITLE) ?> — Manutenzioni</title>
|
||||
<?php if (in_array('fullcalendar', $MNT_PLUGINS, true)): ?>
|
||||
<script src="manutenzioni/assets/fullcalendar.min.js"></script>
|
||||
<script src="manutenzioni/assets/fullcalendar-it.js"></script>
|
||||
<?php endif; ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof window.Swal === 'undefined') {
|
||||
var local = document.createElement('script');
|
||||
local.src = 'manutenzioni/assets/sweetalert2.min.js';
|
||||
document.head.appendChild(local);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--mnt-primary: #2f7d8f;
|
||||
--mnt-primary-hover: #256575;
|
||||
--mnt-heading: #1e3a44;
|
||||
--mnt-card-bg: linear-gradient(135deg, #eef7f9 0%, #e4f0f3 100%);
|
||||
--mnt-card-border: #d6e5e9;
|
||||
}
|
||||
|
||||
.mnt-card { border: none; border-radius: 0.75rem; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); overflow: hidden; }
|
||||
.mnt-card .card-header { background: var(--mnt-card-bg); border-bottom: 1px solid var(--mnt-card-border); padding: 1rem 1.25rem; }
|
||||
.mnt-card .card-header h5 { font-weight: 700; color: var(--mnt-heading); margin: 0; font-size: 1.05rem; letter-spacing: -0.01em; }
|
||||
.mnt-card .card-body { padding: 1.25rem; }
|
||||
|
||||
.btn-mnt-primary { background: var(--mnt-primary); border: none; color: #fff; font-weight: 600; font-size: 0.85rem; padding: 0.5rem 1rem; border-radius: 0.5rem; transition: all 0.2s; }
|
||||
.btn-mnt-primary:hover { background: var(--mnt-primary-hover); color: #fff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(47, 125, 143, 0.3); }
|
||||
.btn-mnt-outline { background: transparent; border: 1.5px solid var(--mnt-primary); color: var(--mnt-primary); font-weight: 600; font-size: 0.85rem; padding: 0.45rem 1rem; border-radius: 0.5rem; transition: all 0.2s; }
|
||||
.btn-mnt-outline:hover { background: var(--mnt-primary); color: #fff; transform: translateY(-1px); }
|
||||
|
||||
.btn-action { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: 0.4rem; font-size: 0.85rem; transition: all 0.15s; text-decoration: none; }
|
||||
.btn-action-view { background: rgba(47, 125, 143, 0.12); color: var(--mnt-primary); }
|
||||
.btn-action-view:hover { background: var(--mnt-primary); color: #fff; }
|
||||
.btn-action-edit { background: rgba(13, 110, 253, 0.12); color: #0d6efd; }
|
||||
.btn-action-edit:hover { background: #0d6efd; color: #fff; }
|
||||
.btn-action-delete { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
|
||||
.btn-action-delete:hover { background: #dc3545; color: #fff; }
|
||||
.btn-action-done { background: rgba(25, 135, 84, 0.12); color: #198754; }
|
||||
.btn-action-done:hover { background: #198754; color: #fff; }
|
||||
|
||||
/* Schedule state badges */
|
||||
.mnt-badge { display: inline-block; padding: 0.22rem 0.6rem; border-radius: 999px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.01em; white-space: nowrap; }
|
||||
.mnt-badge-overdue { background: rgba(220, 53, 69, 0.14); color: #b02a37; }
|
||||
.mnt-badge-soon { background: rgba(255, 193, 7, 0.2); color: #9a7000; }
|
||||
.mnt-badge-ok { background: rgba(25, 135, 84, 0.14); color: #146c43; }
|
||||
.mnt-badge-none { background: rgba(108, 117, 125, 0.14); color: #565e64; }
|
||||
.mnt-badge-critical { background: rgba(220, 53, 69, 0.9); color: #fff; }
|
||||
.mnt-badge-soft { background: rgba(47, 125, 143, 0.12); color: var(--mnt-primary); }
|
||||
.mnt-badge-status-active { background: rgba(25, 135, 84, 0.14); color: #146c43; }
|
||||
.mnt-badge-status-out_of_service { background: rgba(255, 193, 7, 0.2); color: #9a7000; }
|
||||
.mnt-badge-status-decommissioned { background: rgba(108, 117, 125, 0.16); color: #495057; }
|
||||
|
||||
.mnt-cat-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
|
||||
.modal-content > form { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
|
||||
.modal-content > form > .modal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
||||
|
||||
.mnt-title-cell { max-width: 320px; }
|
||||
.mnt-equipment-cell { max-width: 240px; }
|
||||
.mnt-freq-cell { max-width: 190px; }
|
||||
.mnt-person-cell { max-width: 150px; }
|
||||
.mnt-name-cell { max-width: 300px; }
|
||||
.mnt-name-cell .mnt-title-clamp {
|
||||
display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mnt-title-clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; min-width: 0; }
|
||||
.mnt-equipment-cell .mnt-title-clamp { flex: 1 1 auto; }
|
||||
|
||||
/* Mobile-first cards; the table appears from xl (1200px) upwards */
|
||||
.mnt-item-card { background: #fff; border: 1px solid var(--mnt-card-border); border-left: 5px solid var(--row-color, #e9ecef); border-radius: 0.6rem; padding: 0.85rem 0.95rem; margin-bottom: 0.6rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); }
|
||||
.mnt-item-card .ic-title { font-weight: 700; color: var(--mnt-heading); font-size: 0.95rem; word-break: break-word; }
|
||||
.mnt-item-card .ic-meta { font-size: 0.8rem; color: #6c757d; margin-top: 0.3rem; }
|
||||
.mnt-item-card .ic-meta strong { color: var(--mnt-heading); font-weight: 600; }
|
||||
.mnt-item-card .ic-actions { display: flex; gap: 0.4rem; justify-content: flex-end; margin-top: 0.6rem; }
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.d-xl-none > .mnt-item-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 1rem; }
|
||||
.d-xl-none > .mnt-item-card > :not(.ic-actions) { grid-column: 1; }
|
||||
.d-xl-none > .mnt-item-card > .ic-actions { grid-column: 2; grid-row: 1 / -1; margin-top: 0; }
|
||||
}
|
||||
|
||||
.mnt-pager { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem 1.25rem; padding: 0.85rem 0.25rem 0.25rem; font-size: 0.85rem; }
|
||||
.mnt-pager-info { color: #6c757d; }
|
||||
.mnt-pager-info strong { color: var(--mnt-heading); }
|
||||
.mnt-pager-per { display: flex; align-items: center; gap: 0.35rem; }
|
||||
.mnt-pager-pages { display: flex; align-items: center; gap: 0.25rem; margin-left: auto; }
|
||||
.mnt-pager-link { display: inline-flex; align-items: center; justify-content: center; min-width: 2rem; height: 2rem; padding: 0 0.5rem; border: 1px solid var(--mnt-card-border); border-radius: 0.45rem; background: #fff; color: var(--mnt-heading); text-decoration: none; }
|
||||
.mnt-pager-link:hover { border-color: #2f7d8f; color: #2f7d8f; }
|
||||
.mnt-pager-link.is-current { background: #2f7d8f; border-color: #2f7d8f; color: #fff; font-weight: 700; }
|
||||
.mnt-pager-link.is-disabled { opacity: 0.4; pointer-events: none; }
|
||||
.mnt-pager-gap { padding: 0 0.15rem; color: #adb5bd; }
|
||||
@media (max-width: 575.98px) {
|
||||
.mnt-pager-pages { margin-left: 0; width: 100%; justify-content: center; }
|
||||
}
|
||||
|
||||
.mnt-thumb { width: 46px; height: 46px; border-radius: 0.45rem; object-fit: cover; border: 1px solid var(--mnt-card-border); background: #f6f9fa; }
|
||||
.mnt-thumb-placeholder { display: inline-flex; align-items: center; justify-content: center; color: #adb5bd; font-size: 1.1rem; }
|
||||
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: #6c757d; }
|
||||
.empty-state i { font-size: 3rem; opacity: 0.3; margin-bottom: 1rem; display: block; }
|
||||
|
||||
.mnt-filter-bar { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-bottom: 1rem; }
|
||||
.mnt-filter-bar .form-select, .mnt-filter-bar .form-control { min-width: 150px; flex: 1 1 150px; max-width: 100%; }
|
||||
|
||||
.mnt-section-title { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #8a9aa0; margin-bottom: 0.5rem; }
|
||||
.mnt-kv { display: flex; gap: 0.5rem; padding: 0.35rem 0; border-bottom: 1px dashed #eceff1; font-size: 0.88rem; }
|
||||
.mnt-kv:last-child { border-bottom: none; }
|
||||
.mnt-kv dt { flex: 0 0 42%; color: #6c757d; font-weight: 500; margin: 0; }
|
||||
.mnt-kv dd { flex: 1; margin: 0; color: var(--mnt-heading); font-weight: 600; word-break: break-word; }
|
||||
|
||||
.mnt-photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 0.6rem; }
|
||||
.mnt-photo-item { position: relative; border-radius: 0.5rem; overflow: hidden; border: 1px solid var(--mnt-card-border); aspect-ratio: 1; }
|
||||
.mnt-photo-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.mnt-photo-item .photo-tools { position: absolute; inset: auto 0 0 0; display: flex; gap: 0.25rem; justify-content: center; padding: 0.25rem; background: rgba(0, 0, 0, 0.45); opacity: 0; transition: opacity 0.15s; }
|
||||
.mnt-photo-item:hover .photo-tools { opacity: 1; }
|
||||
.mnt-photo-item .photo-tools button { border: none; background: transparent; color: #fff; font-size: 0.8rem; padding: 0.1rem 0.35rem; }
|
||||
.mnt-photo-item.is-cover { border-color: var(--mnt-primary); box-shadow: 0 0 0 2px rgba(47, 125, 143, 0.25); }
|
||||
|
||||
.mnt-file-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0; border-bottom: 1px solid #f1f4f5; font-size: 0.88rem; }
|
||||
.mnt-file-row:last-child { border-bottom: none; }
|
||||
.mnt-file-row .file-name { flex: 1; word-break: break-all; }
|
||||
|
||||
/* Signature pad (tablet) */
|
||||
.mnt-signature-wrap { border: 1px dashed var(--mnt-card-border); border-radius: 0.5rem; background: #fcfdfd; position: relative; }
|
||||
.mnt-signature-wrap canvas { width: 100%; height: 160px; display: block; touch-action: none; border-radius: 0.5rem; }
|
||||
|
||||
.mnt-timeline { position: relative; padding-left: 1.25rem; }
|
||||
.mnt-timeline::before { content: ''; position: absolute; left: 6px; top: 4px; bottom: 4px; width: 2px; background: #e6eef0; }
|
||||
.mnt-timeline-item { position: relative; padding-bottom: 0.9rem; }
|
||||
.mnt-timeline-item::before { content: ''; position: absolute; left: -1.25rem; top: 0.35rem; width: 10px; height: 10px; border-radius: 50%; background: var(--mnt-primary); box-shadow: 0 0 0 3px #fff; }
|
||||
.mnt-timeline-item .tl-date { font-size: 0.75rem; color: #8a9aa0; font-weight: 600; }
|
||||
.mnt-timeline-item .tl-body { font-size: 0.88rem; color: var(--mnt-heading); }
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.mnt-card .card-header { flex-direction: column; gap: 0.75rem; align-items: flex-start !important; }
|
||||
.header-actions { width: 100%; }
|
||||
.header-actions .btn { width: 100%; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Pager shared by the registry and the maintenance list.
|
||||
*
|
||||
* Both lists used to print every row at once — 101 pieces of equipment and 126
|
||||
* maintenance tasks, each rendered twice (cards + table), which on a tablet
|
||||
* came out as a page some 20 000px tall.
|
||||
*
|
||||
* Usage (before including):
|
||||
* $mntPagerUrl = 'manutenzioni/index.php'; // path, relative to <base href>
|
||||
* $mntPagerPage = 3; // current page, 1-based
|
||||
* $mntPagerPages = 7; // number of pages
|
||||
* $mntPagerTotal = 163; // rows matching the current filters
|
||||
* $mntPagerPer = 25; // rows per page
|
||||
*/
|
||||
|
||||
$mntPagerPages = max(1, (int)($mntPagerPages ?? 1));
|
||||
$mntPagerPage = min(max(1, (int)($mntPagerPage ?? 1)), $mntPagerPages);
|
||||
$mntPagerTotal = (int)($mntPagerTotal ?? 0);
|
||||
$mntPagerPer = max(1, (int)($mntPagerPer ?? 25));
|
||||
|
||||
/** Keeps the active filters, drops page when it would be redundant. */
|
||||
$mntPagerLink = static function (int $page, ?int $per = null) use ($mntPagerUrl) {
|
||||
$query = $_GET;
|
||||
unset($query['page'], $query['per_page']);
|
||||
if ($per !== null) {
|
||||
$query['per_page'] = $per;
|
||||
} elseif (isset($_GET['per_page'])) {
|
||||
$query['per_page'] = $_GET['per_page'];
|
||||
}
|
||||
if ($page > 1) {
|
||||
$query['page'] = $page;
|
||||
}
|
||||
|
||||
return $mntPagerUrl . ($query ? '?' . http_build_query($query) : '');
|
||||
};
|
||||
|
||||
$mntFirstRow = $mntPagerTotal ? (($mntPagerPage - 1) * $mntPagerPer) + 1 : 0;
|
||||
$mntLastRow = min($mntPagerPage * $mntPagerPer, $mntPagerTotal);
|
||||
|
||||
// A window around the current page: with 40 pages, 40 links are noise.
|
||||
$mntWindowFrom = max(1, $mntPagerPage - 2);
|
||||
$mntWindowTo = min($mntPagerPages, $mntPagerPage + 2);
|
||||
?>
|
||||
<div class="mnt-pager" data-page="<?= $mntPagerPage ?>" data-pages="<?= $mntPagerPages ?>" data-total="<?= $mntPagerTotal ?>">
|
||||
<div class="mnt-pager-info">
|
||||
<?= $mntFirstRow ?>–<?= $mntLastRow ?> di <strong><?= $mntPagerTotal ?></strong>
|
||||
</div>
|
||||
|
||||
<div class="mnt-pager-per">
|
||||
<span class="text-muted">Per pagina:</span>
|
||||
<?php foreach ([25, 50, 100] as $option): ?>
|
||||
<?php if ($option === $mntPagerPer): ?>
|
||||
<span class="mnt-pager-link is-current"><?= $option ?></span>
|
||||
<?php else: ?>
|
||||
<a class="mnt-pager-link" href="<?= mnt_h($mntPagerLink(1, $option)) ?>"><?= $option ?></a>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($mntPagerPages > 1): ?>
|
||||
<nav class="mnt-pager-pages" aria-label="Paginazione">
|
||||
<?php if ($mntPagerPage > 1): ?>
|
||||
<a class="mnt-pager-link btn-page-prev" rel="prev" href="<?= mnt_h($mntPagerLink($mntPagerPage - 1)) ?>" aria-label="Precedente">‹</a>
|
||||
<?php else: ?>
|
||||
<span class="mnt-pager-link is-disabled">‹</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($mntWindowFrom > 1): ?>
|
||||
<a class="mnt-pager-link" href="<?= mnt_h($mntPagerLink(1)) ?>">1</a>
|
||||
<?php if ($mntWindowFrom > 2): ?><span class="mnt-pager-gap">…</span><?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php for ($p = $mntWindowFrom; $p <= $mntWindowTo; $p++): ?>
|
||||
<?php if ($p === $mntPagerPage): ?>
|
||||
<span class="mnt-pager-link is-current" aria-current="page"><?= $p ?></span>
|
||||
<?php else: ?>
|
||||
<a class="mnt-pager-link" href="<?= mnt_h($mntPagerLink($p)) ?>"><?= $p ?></a>
|
||||
<?php endif; ?>
|
||||
<?php endfor; ?>
|
||||
|
||||
<?php if ($mntWindowTo < $mntPagerPages): ?>
|
||||
<?php if ($mntWindowTo < $mntPagerPages - 1): ?><span class="mnt-pager-gap">…</span><?php endif; ?>
|
||||
<a class="mnt-pager-link" href="<?= mnt_h($mntPagerLink($mntPagerPages)) ?>"><?= $mntPagerPages ?></a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($mntPagerPage < $mntPagerPages): ?>
|
||||
<a class="mnt-pager-link btn-page-next" rel="next" href="<?= mnt_h($mntPagerLink($mntPagerPage + 1)) ?>" aria-label="Successiva">›</a>
|
||||
<?php else: ?>
|
||||
<span class="mnt-pager-link is-disabled">›</span>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="wrapper" id="appWrapper">
|
||||
<script>
|
||||
if (window.innerWidth > 1024) {
|
||||
document.getElementById('appWrapper').classList.add('toggled');
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user