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 '' . '
' . '
' . $safeHeading . '
' . '
' . '

' . $safeSubject . '

' . '

' . $message . '

' . 'Apri la scheda' . '
'; } } /** * 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 ? '
Manutenzione critica.' : ''; if ($isOverdue) { return [ 'subject' => '⚠️ Manutenzione scaduta: ' . $maintenance['equipment_name'], 'body' => mnt_mail_body( 'Manutenzione scaduta', $maintenance['equipment_name'] . ' — ' . $label, 'Era prevista per il ' . $due . ', in ritardo di ' . abs($daysLeft) . ' giorni.' . $criticalNote, '#dc3545', $detailUrl ), ]; } $daysText = $daysLeft === 0 ? 'oggi' : 'tra ' . $daysLeft . ' giorni'; return [ 'subject' => '🔧 Manutenzione in scadenza: ' . $maintenance['equipment_name'], 'body' => mnt_mail_body( 'Manutenzione in scadenza', $maintenance['equipment_name'] . ' — ' . $label, 'È prevista per il ' . $due . ' (' . $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), ]; } }