true, 'secure' => !empty($_SERVER['HTTPS']) || $forwardedHttps, 'samesite' => 'Lax', 'path' => '/']); session_start(); function render_header(string $title): void { $user = current_user(); $brandName = app_setting('company_name', 'JOBcard') ?: 'JOBcard'; $logoFile = app_setting('logo_filename'); $brandMark = $logoFile ? '' : ''; $brandLabel = $logoFile ? $brandMark : e($brandName); echo '' . e($title) . ' · ' . e($brandName) . ''; if ($user) { echo '
'; } else { echo '
'; } } function render_footer(): void { $user = current_user(); echo '
' . ($user ? '
' : '') . ''; } $route = scalar_input($_GET['route'] ?? null, current_user() ? 'dashboard' : 'login'); if ($route === 'logout') { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); exit('Logout requires POST'); } verify_csrf(); if (current_user()) audit('logout', 'user', (int)current_user()['id']); $_SESSION = []; session_destroy(); header('Location: /?route=login'); exit; } if ($route === 'login') { if (current_user()) { header('Location: /?route=dashboard'); exit; } $error = null; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $stmt = db()->prepare('SELECT u.*, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.email = :email LIMIT 1'); $stmt->execute(['email' => strtolower(trim(scalar_input($_POST['email'] ?? null))) ]); $user = $stmt->fetch(); if (!$user || !$user['is_active'] || !password_verify(scalar_input($_POST['password'] ?? null), $user['password_hash'])) { $error = 'The email or password is incorrect.'; } else { session_regenerate_id(true); $_SESSION['user_id'] = (int)$user['id']; $_SESSION['csrf'] = bin2hex(random_bytes(32)); db()->prepare('UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = :id')->execute(['id' => $user['id']]); audit('login_success', 'user', (int)$user['id']); header('Location: /?route=dashboard'); exit; } } render_header('Sign in'); ?>

JOBcard

Sign in to the support workspace.

query("SELECT SUM(status = 'new') AS new_count, SUM(status NOT IN ('completed','closed')) AS open_count FROM jobcards")->fetch(); $hoursThisWeek = (float)db()->query("SELECT COALESCE(SUM(hours), 0) FROM time_entries WHERE work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND work_date <= CURDATE() AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = time_entries.id AND av.action = 'time_entry_voided')")->fetchColumn(); $slaRows = db()->query("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR (s.period_type = 'custom' AND te.work_date BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, CURDATE()))) THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s LEFT JOIN jobcards j ON j.client_id = s.client_id LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided') WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date")->fetchAll(); if ($user['role_name'] === 'Technician') { $metricStmt = db()->prepare("SELECT SUM(j.status = 'new') AS new_count, SUM(j.status NOT IN ('completed','closed')) AS open_count FROM jobcards j JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user"); $metricStmt->execute(['user' => $user['id']]); $jobcardMetrics = $metricStmt->fetch(); $hoursStmt = db()->prepare("SELECT COALESCE(SUM(te.hours), 0) FROM time_entries te JOIN jobcard_assignments ja ON ja.jobcard_id = te.jobcard_id AND ja.user_id = :user WHERE te.work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND te.work_date <= CURDATE() AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided')"); $hoursStmt->execute(['user' => $user['id']]); $hoursThisWeek = (float)$hoursStmt->fetchColumn(); $slaRowsStmt = db()->prepare("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND te.work_date >= COALESCE(s.start_date, '1000-01-01') AND te.work_date <= COALESCE(s.end_date, CURDATE()) AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR s.period_type = 'custom') THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s JOIN jobcards j ON j.client_id = s.client_id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided') WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date"); $slaRowsStmt->execute(['user' => $user['id']]); $slaRows = $slaRowsStmt->fetchAll(); } $urgentJobcards = []; if ($user['role_name'] === 'Technician') { $urgentStmt = db()->prepare("SELECT DISTINCT j.id, j.reference_no, j.status, j.priority, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user WHERE j.priority IN ('high', 'critical') AND j.status <> 'closed' ORDER BY FIELD(j.priority, 'critical', 'high'), j.created_at ASC LIMIT 10"); $urgentStmt->execute(['user' => $user['id']]); $urgentJobcards = $urgentStmt->fetchAll(); } $slaClassifier = new \App\Domain\SLA\SlaThresholdClassifier(); $slaWarnings = 0; foreach ($slaRows as $slaRow) if (in_array($slaClassifier->classify((float)$slaRow['used_hours'], (float)$slaRow['allocated_hours']), ['warning', 'critical', 'exceeded'], true)) $slaWarnings++; render_header('Dashboard'); ?>

Dashboard

Your operational overview.

New jobcards
Open jobcards
Hours this week
SLA warnings

Operations

Use Jobcards to manage assignments, status, technician notes and time entries. SLA threshold metrics will activate after period and rollover rules are configured.

Urgent jobcards

High and critical priority work assigned to you.

No urgent active jobcards.

'clients.view','jobcards'=>'jobcards.view','reports'=>'reports.view','users'=>'users.manage','audit'=>'audit.view']; if ($route === 'attachment') { require_login(); $attachmentId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT); $attachmentStmt = db()->prepare('SELECT a.*, j.id AS jobcard_id FROM attachments a JOIN jobcards j ON j.id = a.jobcard_id WHERE a.id = :id'); $attachmentStmt->execute(['id' => $attachmentId]); $attachment = $attachmentStmt->fetch(); if (!$attachment || !can_access_jobcard((int)$attachment['jobcard_id']) || !can('attachments.view')) { http_response_code(404); exit('Attachment not found'); } $path = dirname(__DIR__) . '/storage/uploads/' . basename($attachment['stored_name']); if (!is_file($path) || !is_readable($path)) { http_response_code(404); exit('Attachment not found'); } audit('attachment_downloaded', 'attachment', $attachmentId, ['jobcard_id' => (int)$attachment['jobcard_id']]); header('Content-Type: ' . $attachment['mime_type']); header('Content-Length: ' . (string)filesize($path)); header('Content-Disposition: attachment; filename="' . str_replace('"', '', $attachment['original_name']) . '"'); header('X-Content-Type-Options: nosniff'); readfile($path); exit; } if ($route === 'credential_reveal') { require_permission('credentials.view'); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); exit('Reveal requires POST'); } verify_csrf(); $credentialId = filter_var(scalar_input($_POST['credential_id'] ?? null), FILTER_VALIDATE_INT); $credentialStmt = db()->prepare('SELECT id, client_id, secret_ciphertext FROM credentials WHERE id = :id AND is_active = 1'); $credentialStmt->execute(['id' => $credentialId]); $credential = $credentialStmt->fetch(); if (!$credential || !can_access_client((int)$credential['client_id'])) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(['ok' => false, 'error' => 'Credential not found.']); exit; } try { $secret = (new \App\Domain\Credential\CredentialVault())->decrypt($credential['secret_ciphertext']); audit('credential_revealed', 'credential', (int)$credential['id'], ['client_id' => (int)$credential['client_id']]); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Content-Type: application/json; charset=UTF-8'); echo json_encode(['ok' => true, 'secret' => $secret], JSON_THROW_ON_ERROR); } catch (Throwable) { http_response_code(500); header('Content-Type: application/json'); echo json_encode(['ok' => false, 'error' => 'Credential could not be decrypted.']); } exit; } if ($route === 'client_history') { require_permission('clients.view'); $clientId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT); if (!$clientId || !can_access_client($clientId)) { http_response_code(404); exit('Client not found'); } try { $filters = \ReportFilters::fromArray([...$_GET, 'client_id' => $clientId]); } catch (Throwable $exception) { http_response_code(400); exit('Invalid history filters'); } $stmt = db()->prepare('SELECT h.id, j.client_id, j.reference_no, h.from_status, h.to_status, h.changed_at, u.name AS changed_by_name FROM jobcard_status_history h JOIN jobcards j ON j.id = h.jobcard_id LEFT JOIN users u ON u.id = h.changed_by WHERE j.client_id = :client ORDER BY h.changed_at ASC, h.id ASC'); $stmt->execute(['client' => $clientId]); $history = (new \App\Domain\Reporting\ClientHistoryReport($filters))->build($stmt->fetchAll(), 'client'); if (scalar_input($_GET['format'] ?? null) === 'print') { header('Content-Type: text/html; charset=UTF-8'); echo (new \PrintReportRenderer())->render('Client history', ['Reference', 'From', 'To', 'Changed'], array_map(static fn (array $row): array => [$row['reference_no'], $row['from_status'], $row['to_status'], $row['changed_at']], $history)); exit; } render_header('Client history'); echo '
← Back to client

Client history

Print view
'; foreach ($history as $row) echo ''; echo '
JobcardFromToChanged
' . e($row['reference_no']) . '' . e($row['from_status']) . '' . e($row['to_status']) . '' . e($row['changed_at']) . '
'; render_footer(); exit; } if ($route === 'time_entry') { require_permission('time_entries.record'); $entryId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT); $stmt = db()->prepare("SELECT t.*, j.client_id FROM time_entries t JOIN jobcards j ON j.id = t.jobcard_id WHERE t.id = :id AND NOT EXISTS (SELECT 1 FROM audit_events ae WHERE ae.entity_type = 'time_entry' AND ae.entity_id = t.id AND ae.action = 'time_entry_voided')"); $stmt->execute(['id' => $entryId]); $entry = $stmt->fetch(); if (!$entry || !can_access_jobcard((int)$entry['jobcard_id']) || ($user['role_name'] === 'Technician' && (int)$entry['technician_id'] !== (int)$user['id'])) { http_response_code(404); exit('Time entry not found'); } $errors = []; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $command = scalar_input($_POST['action'] ?? null); $auditAction = $command === 'void' ? 'time_entry_voided' : 'time_entry_corrected'; $validator = new \App\Domain\Jobcard\TimeEntryCorrectionCommand(); $changes = []; foreach (['work_date', 'start_time', 'end_time', 'hours', 'notes', 'counts_toward_sla'] as $field) if (array_key_exists($field, $_POST)) $changes[$field] = $_POST[$field]; $result = $command === 'void' ? $validator->validateVoid($entry, ['reason' => $_POST['reason'] ?? null]) : $validator->validateCorrection($entry, $changes); $errors = array_values($result['errors']); if (!$errors) { if ($command === 'void') audit($auditAction, 'time_entry', $entryId, ['reason' => $result['void_reason']]); else { db()->prepare('UPDATE time_entries SET work_date = :date, start_time = :start, end_time = :end, hours = :hours, notes = :notes, counts_toward_sla = :sla WHERE id = :id')->execute(['date' => $result['entry']['work_date'], 'start' => $result['entry']['start_time'] ?? null, 'end' => $result['entry']['end_time'] ?? null, 'hours' => $result['entry']['hours'], 'notes' => $result['entry']['notes'] ?? null, 'sla' => !empty($result['entry']['counts_toward_sla']) ? 1 : 0, 'id' => $entryId]); audit($auditAction, 'time_entry', $entryId); } header('Location: /?route=jobcard&id=' . (int)$entry['jobcard_id'] . '&updated=1'); exit; } } render_header('Time entry correction'); echo '

Correct or void time entry

' . ($errors ? '
' . e(implode(' ', $errors)) . '
' : '') . '
'; render_footer(); exit; } if ($route === 'jobcard') { require_permission('jobcards.view'); $jobcardId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT); if (!$jobcardId) { http_response_code(400); exit('Invalid jobcard'); } $jobcardStmt = db()->prepare('SELECT j.*, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id WHERE j.id = :id'); $jobcardStmt->execute(['id' => $jobcardId]); $jobcard = $jobcardStmt->fetch(); if (!$jobcard) { http_response_code(404); exit('Jobcard not found'); } if (!can_access_jobcard($jobcardId)) { http_response_code(404); exit('Jobcard not found'); } $actionErrors = []; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $action = scalar_input($_POST['action'] ?? null); if ($action === 'status') { require_permission('jobcards.manage'); $to = scalar_input($_POST['status'] ?? null); if ($to === 'closed') require_permission('jobcards.close'); $pdo = db(); try { $pdo->beginTransaction(); $lockedStmt = $pdo->prepare('SELECT status, completed_at, closed_at FROM jobcards WHERE id = :id FOR UPDATE'); $lockedStmt->execute(['id' => $jobcardId]); $locked = $lockedStmt->fetch(); if (!$locked) throw new RuntimeException('Jobcard no longer exists.'); if ($locked['status'] === 'closed' && $to !== 'closed') require_permission('jobcards.reopen'); $now = date('Y-m-d H:i:s'); $completedAt = $to === 'completed' ? $now : ($locked['completed_at'] ?: null); $closedAt = $to === 'closed' ? $now : ($locked['closed_at'] ?: null); if ($locked['status'] === 'closed' && $to === 'in_progress') { $completedAt = null; $closedAt = null; } if ($to === 'closed' && $completedAt === null) $completedAt = $now; $transition = (new \App\Domain\Jobcard\JobcardWorkflow())->validateTransition($locked['status'], $to, $completedAt, $closedAt); if (!$transition['valid']) { $pdo->rollBack(); $actionErrors = array_values($transition['errors']); } else { $pdo->prepare('UPDATE jobcards SET status = :status, completed_at = :completed, closed_at = :closed WHERE id = :id')->execute(['status' => $to, 'completed' => $completedAt, 'closed' => $closedAt, 'id' => $jobcardId]); $pdo->prepare('INSERT INTO jobcard_status_history (jobcard_id, from_status, to_status, changed_by) VALUES (:jobcard, :from_status, :to_status, :user)')->execute(['jobcard' => $jobcardId, 'from_status' => $locked['status'], 'to_status' => $to, 'user' => $user['id']]); audit('jobcard_status_changed', 'jobcard', $jobcardId, ['from' => $locked['status'], 'to' => $to]); $pdo->commit(); try { $recipientStmt = db()->prepare('SELECT u.email FROM users u JOIN jobcard_assignments ja ON ja.user_id = u.id WHERE ja.jobcard_id = :jobcard AND u.is_active = 1'); $recipientStmt->execute(['jobcard' => $jobcardId]); $recipients = array_column($recipientStmt->fetchAll(), 'email'); if ($recipients) (new \App\Domain\Notification\NotificationQueue())->enqueue(db(), ['type' => 'jobcard_status_changed', 'recipients' => $recipients, 'title' => 'Jobcard status changed', 'body' => 'Jobcard ' . $jobcard['reference_no'] . ' changed to ' . $to . '.', 'deduplication_key' => 'jobcard:' . $jobcardId . ':status:' . $to]); } catch (Throwable) { /* notification failure must not undo a committed status transition */ } header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; } } catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $actionErrors[] = 'Status update failed.'; } } elseif ($action === 'notes') { require_permission('jobcards.manage'); $technicianNotes = trim(scalar_input($_POST['technician_notes'] ?? null)); $internalNotes = can('jobcards.internal_notes') ? trim(scalar_input($_POST['internal_notes'] ?? null)) : (string)($jobcard['internal_notes'] ?? ''); if (mb_strlen($technicianNotes) > 50000 || mb_strlen($internalNotes) > 50000) $actionErrors[] = 'Notes are too long.'; if (!$actionErrors) { db()->prepare('UPDATE jobcards SET technician_notes = :technician, internal_notes = :internal WHERE id = :id')->execute(['technician' => $technicianNotes ?: null, 'internal' => $internalNotes ?: null, 'id' => $jobcardId]); audit('jobcard_notes_updated', 'jobcard', $jobcardId); header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; } } elseif ($action === 'assign') { require_permission('jobcards.assign'); $assignment = (new \App\Domain\Jobcard\AssignmentValidator())->validate(['jobcard_id' => $jobcardId, 'user_ids' => [$_POST['technician_id'] ?? null]]); $actionErrors = array_values($assignment['errors']); $technicianId = $assignment['user_ids'][0] ?? null; $technicianCheck = db()->prepare("SELECT u.id FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = :id AND u.is_active = 1 AND r.name = 'Technician'"); $technicianCheck->execute(['id' => $technicianId]); if (!$technicianCheck->fetchColumn()) $actionErrors[] = 'Select an active technician.'; if (!$actionErrors) { $pdo = db(); try { $pdo->beginTransaction(); $pdo->prepare('DELETE FROM jobcard_assignments WHERE jobcard_id = :jobcard')->execute(['jobcard' => $jobcardId]); $pdo->prepare('INSERT INTO jobcard_assignments (jobcard_id, user_id, assigned_by) VALUES (:jobcard, :user, :by_user)')->execute(['jobcard' => $jobcardId, 'user' => $technicianId, 'by_user' => $user['id']]); audit('jobcard_assigned', 'jobcard', $jobcardId, ['technician_id' => $technicianId]); $pdo->commit(); try { $recipientStmt = db()->prepare('SELECT email FROM users WHERE id = :id AND is_active = 1'); $recipientStmt->execute(['id' => $technicianId]); $recipient = $recipientStmt->fetchColumn(); if ($recipient) (new \App\Domain\Notification\NotificationQueue())->enqueue(db(), ['type' => 'assignment_created', 'recipients' => [$recipient], 'title' => 'Jobcard assigned', 'body' => 'Jobcard ' . $jobcard['reference_no'] . ' was assigned to you.', 'deduplication_key' => 'assignment:' . $jobcardId . ':' . $technicianId]); } catch (Throwable) { /* notification failure must not undo a committed assignment */ } header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; } catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $actionErrors[] = 'Assignment update failed.'; } } } elseif ($action === 'time') { require_permission('time_entries.record'); $technicianId = $user['role_name'] === 'Technician' ? (int)$user['id'] : filter_var(scalar_input($_POST['technician_id'] ?? null), FILTER_VALIDATE_INT); $technicianCheck = db()->prepare("SELECT u.id FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = :id AND u.is_active = 1 AND r.name = 'Technician'"); $technicianCheck->execute(['id' => $technicianId]); $timeInput = [...$_POST, 'jobcard_id' => $jobcardId, 'technician_id' => $technicianId, 'counts_toward_sla' => isset($_POST['counts_toward_sla']) ? '1' : '0']; $time = (new \App\Domain\Jobcard\TimeEntryCommand())->validate($timeInput); $actionErrors = array_values($time['errors']); if (!$technicianCheck->fetchColumn()) $actionErrors[] = 'Time must be attributed to an active technician.'; if (!$actionErrors) { db()->prepare('INSERT INTO time_entries (jobcard_id, technician_id, work_date, start_time, end_time, hours, notes, counts_toward_sla, created_by) VALUES (:jobcard, :technician, :work_date, :start_time, :end_time, :hours, :notes, :sla, :created_by)')->execute(['jobcard' => $jobcardId, 'technician' => $time['technician_id'], 'work_date' => $time['work_date'], 'start_time' => $time['start_time'], 'end_time' => $time['end_time'], 'hours' => $time['hours'], 'notes' => $time['notes'], 'sla' => $time['counts_toward_sla'] ? 1 : 0, 'created_by' => $user['id']]); audit('time_entry_created', 'jobcard', $jobcardId, ['hours' => $time['hours']]); header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; } } elseif ($action === 'attachment') { require_permission('attachments.manage'); $file = $_FILES['attachment'] ?? null; if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) { $actionErrors[] = 'Select a valid attachment.'; } else { $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($file['tmp_name']); $signatureValid = match ($mime) { 'image/png' => substr((string)file_get_contents($file['tmp_name'], false, null, 0, 8), 0, 8) === "\x89PNG\x0D\x0A\x1A\x0A", 'image/jpeg' => substr((string)file_get_contents($file['tmp_name'], false, null, 0, 3), 0, 3) === "\xFF\xD8\xFF", 'image/gif' => in_array(substr((string)file_get_contents($file['tmp_name'], false, null, 0, 6), 0, 6), ['GIF87a', 'GIF89a'], true), 'application/pdf' => str_starts_with((string)file_get_contents($file['tmp_name'], false, null, 0, 5), '%PDF-'), default => true, }; if (!$signatureValid) $actionErrors[] = 'Attachment content does not match its detected type.'; if ($actionErrors) { /* validation stops before storage */ } else { $attachment = (new \App\Domain\Attachment\AttachmentValidator())->validate(['name' => $file['name'] ?? '', 'mime_type' => $mime, 'size_bytes' => $file['size'] ?? -1, 'client_visible' => isset($_POST['client_visible']), 'client_approved' => isset($_POST['client_approved'])]); $actionErrors = array_values($attachment['errors']); if (!$actionErrors) { $uploadDir = dirname(__DIR__) . '/storage/uploads'; if (!is_dir($uploadDir) && !mkdir($uploadDir, 0750, true) && !is_dir($uploadDir)) $actionErrors[] = 'Attachment storage is unavailable.'; if (!$actionErrors) { $storedName = bin2hex(random_bytes(24)) . '.' . $attachment['extension']; if (!move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $storedName)) $actionErrors[] = 'Attachment could not be stored.'; else { try { db()->beginTransaction(); db()->prepare('INSERT INTO attachments (jobcard_id, original_name, stored_name, mime_type, file_size, client_visible, uploaded_by) VALUES (:jobcard, :original, :stored, :mime, :size, :visible, :user)')->execute(['jobcard' => $jobcardId, 'original' => $attachment['name'], 'stored' => $storedName, 'mime' => $attachment['mime_type'], 'size' => $attachment['size_bytes'], 'visible' => $attachment['client_visible'] ? 1 : 0, 'user' => $user['id']]); $attachmentId = (int)db()->lastInsertId(); audit('attachment_uploaded', 'attachment', $attachmentId, ['jobcard_id' => $jobcardId]); db()->commit(); header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; } catch (Throwable $exception) { if (db()->inTransaction()) db()->rollBack(); @unlink($uploadDir . '/' . $storedName); $actionErrors[] = 'Attachment metadata could not be saved.'; } } } } } } } } $jobcardStmt->execute(['id' => $jobcardId]); $jobcard = $jobcardStmt->fetch(); $assignments = db()->prepare('SELECT u.id, u.name FROM jobcard_assignments a JOIN users u ON u.id = a.user_id WHERE a.jobcard_id = :id ORDER BY u.name'); $assignments->execute(['id' => $jobcardId]); $assigned = $assignments->fetchAll(); $technicians = db()->query("SELECT u.id, u.name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.is_active = 1 AND r.name = 'Technician' ORDER BY u.name")->fetchAll(); $timeStmt = db()->prepare("SELECT t.*, u.name AS technician_name FROM time_entries t JOIN users u ON u.id = t.technician_id WHERE t.jobcard_id = :id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = t.id AND av.action = 'time_entry_voided') ORDER BY t.work_date DESC, t.id DESC"); $timeStmt->execute(['id' => $jobcardId]); $timeEntries = $timeStmt->fetchAll(); $attachmentStmt = db()->prepare('SELECT id, original_name, mime_type, file_size, client_visible, created_at FROM attachments WHERE jobcard_id = :id ORDER BY created_at DESC'); $attachmentStmt->execute(['id' => $jobcardId]); $attachments = $attachmentStmt->fetchAll(); $totalHours = array_sum(array_map(static fn (array $entry): float => (float)$entry['hours'], $timeEntries)); render_header('Jobcard ' . $jobcard['reference_no']); echo '
← Back to jobcards

' . e($jobcard['reference_no']) . '

' . e($jobcard['client_name']) . '

' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '
' . (isset($_GET['updated']) ? '
Jobcard updated.
' : '') . ($actionErrors ? '
' . e(implode(' ', $actionErrors)) . '
' : ''); echo '

Work requested

' . nl2br(e($jobcard['work_requested'])) . '

'; if (can('jobcards.internal_notes')) echo '

Internal notes

'; echo '

Time entries

' . e(number_format($totalHours, 2)) . ' hours
'; foreach ($timeEntries as $entry) echo '
' . e($entry['technician_name']) . ' · ' . e($entry['work_date']) . ' · ' . e(number_format((float)$entry['hours'], 2)) . 'h
' . e((string)($entry['notes'] ?? '')) . '
' . (can('time_entries.record') ? 'Correct or void' : '') . '
'; if (can('time_entries.record')) { echo '
'; if ($user['role_name'] !== 'Technician') { echo '
'; } echo '
'; } echo '

Status

Assigned technicians

'; if (!$assigned) echo '

No technicians assigned.

'; foreach ($assigned as $assignment) echo '
' . e($assignment['name']) . '
'; if (can('jobcards.assign')) { echo '
'; } echo '
'; if (can('attachments.view') || can('attachments.manage')) { echo '

Attachments

'; if (!$attachments) echo '

No attachments.

'; foreach ($attachments as $attachment) echo '
' . e($attachment['original_name']) . ' ' . e($attachment['mime_type']) . ' · ' . e((string)$attachment['file_size']) . ' bytes · ' . ($attachment['client_visible'] ? 'Client approved' : 'Internal') . '
'; if (can('attachments.manage')) echo '
'; echo '
'; } render_footer(); exit; } if ($route === 'jobcards') { require_permission('jobcards.view'); $errors = []; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { require_permission('jobcards.manage'); verify_csrf(); $command = (new \App\Domain\Jobcard\JobcardWorkflow())->validateCommand($_POST); $clientId = $command['client_id']; $workRequested = $command['work_requested']; $priority = $command['priority']; $errors = array_values($command['errors']); if (!$errors) { $clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'" . ($user['role_name'] === 'Technician' ? ' AND EXISTS (SELECT 1 FROM jobcards assigned_j JOIN jobcard_assignments assigned_a ON assigned_a.jobcard_id = assigned_j.id WHERE assigned_j.client_id = clients.id AND assigned_a.user_id = :user)' : '')); $clientCheck->execute($user['role_name'] === 'Technician' ? ['id' => $clientId, 'user' => $user['id']] : ['id' => $clientId]); if (!$clientCheck->fetchColumn()) $errors[] = 'The selected client is not active or does not exist.'; } if (!$errors) { $year = (int)date('Y'); $pdo = db(); try { $pdo->beginTransaction(); $sequenceStmt = $pdo->prepare('INSERT INTO jobcard_sequences (sequence_year, next_sequence) VALUES (:year, 2) ON DUPLICATE KEY UPDATE next_sequence = next_sequence + 1'); $sequenceStmt->execute(['year' => $year]); $sequenceStmt = $pdo->prepare('SELECT next_sequence - 1 FROM jobcard_sequences WHERE sequence_year = :year FOR UPDATE'); $sequenceStmt->execute(['year' => $year]); $sequence = (int)$sequenceStmt->fetchColumn(); $reference = \App\Domain\Jobcard\JobcardReference::generate($sequence, $year); $stmt = $pdo->prepare('INSERT INTO jobcards (reference_no, client_id, created_by, priority, status, work_requested) VALUES (:reference, :client, :created_by, :priority, \'new\', :requested)'); $stmt->execute(['reference' => $reference, 'client' => $clientId, 'created_by' => $user['id'], 'priority' => $priority, 'requested' => $workRequested]); $jobcardId = (int)$pdo->lastInsertId(); if ($user['role_name'] === 'Technician') $pdo->prepare('INSERT IGNORE INTO jobcard_assignments (jobcard_id, user_id, assigned_by) VALUES (:jobcard, :user, :by_user)')->execute(['jobcard' => $jobcardId, 'user' => $user['id'], 'by_user' => $user['id']]); audit('jobcard_created', 'jobcard', $jobcardId, ['reference_no' => $reference]); $pdo->commit(); header('Location: /?route=jobcards&created=1'); exit; } catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $errors[] = 'The jobcard could not be created. Please try again.'; } } } $search = trim(scalar_input($_GET['q'] ?? null)); $statusFilter = scalar_input($_GET['status_filter'] ?? null, 'open'); if (!in_array($statusFilter, ['open', 'in_progress', 'assigned', 'closed'], true)) $statusFilter = 'open'; $conditions = []; $params = []; if ($search !== '') { $conditions[] = '(j.reference_no LIKE ? OR c.name LIKE ? OR j.work_requested LIKE ?)'; $like = '%' . $search . '%'; array_push($params, $like, $like, $like); } if ($statusFilter === 'closed') { $conditions[] = "j.status = 'closed'"; } elseif ($statusFilter === 'in_progress') { $conditions[] = "j.status = 'in_progress'"; } elseif ($statusFilter === 'assigned') { $conditions[] = "j.status = 'assigned'"; } else { $conditions[] = "j.status <> 'closed'"; } $scopeJoin = ''; if ($user['role_name'] === 'Technician') { $scopeJoin = ' JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ?'; array_unshift($params, (int)$user['id']); } if ($user['role_name'] === 'Technician') { $clientListForJobcard = db()->prepare("SELECT DISTINCT c.id, c.name FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user WHERE c.status = 'active' ORDER BY c.name"); $clientListForJobcard->execute(['user' => $user['id']]); $clients = $clientListForJobcard->fetchAll(); } else { $clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll(); } $jobcardList = db()->prepare('SELECT DISTINCT j.id, j.reference_no, j.priority, j.status, j.work_requested, j.created_at, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id' . $scopeJoin . ' WHERE ' . implode(' AND ', $conditions) . ' ORDER BY j.created_at DESC LIMIT 100'); $jobcardList->execute($params); $jobcards = $jobcardList->fetchAll(); render_header('Jobcards'); echo '

Jobcards

Track requested work and operational status.

'; if (can('jobcards.manage')) echo ''; echo '
'; if (isset($_GET['created'])) echo '
Jobcard created successfully.
'; if ($errors) echo '
' . e(implode(' ', $errors)) . '
'; echo '
'; if (can('jobcards.manage')) { echo '

Create jobcard

'; } echo '
'; if (!$jobcards) echo ''; foreach ($jobcards as $jobcard) echo ''; echo '
ReferenceClientPriorityStatusWork requestedCreated
No jobcards found.
' . e($jobcard['reference_no']) . '' . e($jobcard['client_name']) . '' . e(ucfirst($jobcard['priority'])) . '' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '' . e($jobcard['work_requested']) . '' . e($jobcard['created_at']) . '
'; render_footer(); exit; } if ($route === 'client') { require_permission('clients.view'); $clientId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT); if (!$clientId) { http_response_code(400); exit('Invalid client'); } if (!can_access_client($clientId)) { http_response_code(404); exit('Client not found'); } $stmt = db()->prepare('SELECT * FROM clients WHERE id = :id'); $stmt->execute(['id' => $clientId]); $client = $stmt->fetch(); if (!$client) { http_response_code(404); exit('Client not found'); } $contactErrors = []; $contactOld = ['name' => '', 'email' => '', 'phone' => '', 'is_primary' => false]; $slaErrors = []; $credentialErrors = []; $revealedCredential = null; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $clientAction = scalar_input($_POST['action'] ?? null, 'contact'); if ($clientAction === 'credential_reveal') { require_permission('credentials.view'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); $credentialId = filter_var(scalar_input($_POST['credential_id'] ?? null), FILTER_VALIDATE_INT); $credentialStmt = db()->prepare('SELECT * FROM credentials WHERE id = :id AND client_id = :client AND is_active = 1'); $credentialStmt->execute(['id' => $credentialId, 'client' => $clientId]); $credential = $credentialStmt->fetch(); if (!$credential) $credentialErrors[] = 'Credential not found.'; else { try { $revealedCredential = ['id' => (int)$credential['id'], 'secret' => (new \App\Domain\Credential\CredentialVault())->decrypt($credential['secret_ciphertext'])]; audit('credential_revealed', 'credential', (int)$credential['id'], ['client_id' => $clientId]); } catch (Throwable $exception) { $credentialErrors[] = 'Credential could not be decrypted.'; } } } elseif ($clientAction === 'client_update') { require_permission('clients.manage'); $existingClients = db()->query('SELECT id, name FROM clients')->fetchAll(); $clientUpdate = (new \App\Domain\Client\ClientUpdateCommand())->validateForEdit($clientId, $_POST, $existingClients); $contactErrors = $clientUpdate['errors']; if (!$contactErrors) { $stmt = db()->prepare('UPDATE clients SET name = :name, registration_number = :registration, status = :status, support_email = :email, support_phone = :phone, preferred_contact_method = :method, physical_address = :physical, postal_address = :postal, general_notes = :notes WHERE id = :id'); $stmt->execute(['name' => $clientUpdate['name'], 'registration' => $clientUpdate['registration_number'], 'status' => $clientUpdate['status'], 'email' => $clientUpdate['support_email'], 'phone' => $clientUpdate['support_phone'], 'method' => $clientUpdate['preferred_contact_method'], 'physical' => $clientUpdate['physical_address'], 'postal' => $clientUpdate['postal_address'], 'notes' => $clientUpdate['general_notes'], 'id' => $clientId]); audit('client_updated', 'client', $clientId); header('Location: /?route=client&id=' . $clientId . '&client_updated=1'); exit; } } elseif ($clientAction === 'sla') { require_permission('sla.manage'); $sla = (new \App\Domain\SLA\SlaAgreement())->validate([...$_POST, 'client_id' => $clientId, 'enabled' => isset($_POST['enabled']) ? '1' : '0', 'rollover_enabled' => isset($_POST['rollover_enabled']) ? '1' : '0']); $slaErrors = array_values($sla['errors']); if (!$slaErrors) { db()->prepare('INSERT INTO sla_agreements (client_id, enabled, agreement_type, allocated_hours, period_type, start_date, end_date, rollover_enabled, notes) VALUES (:client, :enabled, :type, :hours, :period, :start_date, :end_date, :rollover, :notes) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), agreement_type = VALUES(agreement_type), allocated_hours = VALUES(allocated_hours), period_type = VALUES(period_type), start_date = VALUES(start_date), end_date = VALUES(end_date), rollover_enabled = VALUES(rollover_enabled), notes = VALUES(notes)')->execute(['client' => $clientId, 'enabled' => $sla['enabled'] ? 1 : 0, 'type' => $sla['agreement_type'], 'hours' => $sla['allocated_hours'], 'period' => $sla['period_type'], 'start_date' => $sla['start_date'], 'end_date' => $sla['end_date'], 'rollover' => $sla['rollover_enabled'] ? 1 : 0, 'notes' => $sla['notes']]); audit('sla_agreement_updated', 'client', $clientId); header('Location: /?route=client&id=' . $clientId . '&sla_updated=1'); exit; } } elseif ($clientAction === 'credential') { require_permission('credentials.manage'); try { $vault = new \App\Domain\Credential\CredentialVault(); $storedCredential = $vault->encryptCredential(['category' => $_POST['category'] ?? null, 'label' => $_POST['label'] ?? null, 'username' => $_POST['username'] ?? null, 'notes' => $_POST['credential_notes'] ?? null, 'secret' => scalar_input($_POST['secret'] ?? null)]); $credentialInsert = db()->prepare('INSERT INTO credentials (client_id, category, label, username, secret_ciphertext, notes, created_by) VALUES (:client, :category, :label, :username, :ciphertext, :notes, :user)'); $credentialInsert->execute(['client' => $clientId, 'category' => $storedCredential['category'], 'label' => $storedCredential['label'], 'username' => $storedCredential['username'], 'ciphertext' => $storedCredential['secret_ciphertext'], 'notes' => $storedCredential['notes'], 'user' => $user['id']]); $credentialId = (int)db()->lastInsertId(); audit('credential_created', 'credential', $credentialId, ['client_id' => $clientId]); header('Location: /?route=client&id=' . $clientId . '&credential_created=1'); exit; } catch (Throwable $exception) { $credentialErrors[] = 'Credential could not be saved.'; } } elseif (in_array($clientAction, ['contact_edit', 'contact_delete', 'contact_primary'], true)) { require_permission('clients.manage'); $contactId = filter_var(scalar_input($_POST['contact_id'] ?? null), FILTER_VALIDATE_INT); $existingStmt = db()->prepare('SELECT id, client_id, name, email, phone, is_primary, notes FROM client_contacts WHERE client_id = :client ORDER BY id'); $existingStmt->execute(['client' => $clientId]); $existingContacts = $existingStmt->fetchAll(); $editor = new \App\Domain\Client\ContactEditCommand(); $result = $clientAction === 'contact_delete' ? $editor->validateDelete((int)$contactId, $existingContacts) : ($clientAction === 'contact_primary' ? $editor->validatePrimary((int)$contactId, $existingContacts) : $editor->validateForEdit((int)$contactId, [...$_POST, 'client_id' => $clientId], $existingContacts)); $contactErrors = array_values($result['errors']); if (!$contactErrors) { $pdo = db(); try { $pdo->beginTransaction(); if ($clientAction === 'contact_delete') { $pdo->prepare('DELETE FROM client_contacts WHERE id = :id AND client_id = :client')->execute(['id' => $contactId, 'client' => $clientId]); if (!empty($result['replacement_primary_contact_id'])) $pdo->prepare('UPDATE client_contacts SET is_primary = 1 WHERE id = :id AND client_id = :client')->execute(['id' => $result['replacement_primary_contact_id'], 'client' => $clientId]); audit('client_contact_deleted', 'client_contact', (int)$contactId, ['client_id' => $clientId]); } elseif ($clientAction === 'contact_primary') { $pdo->prepare('UPDATE client_contacts SET is_primary = 0 WHERE client_id = :client')->execute(['client' => $clientId]); $pdo->prepare('UPDATE client_contacts SET is_primary = 1 WHERE id = :id AND client_id = :client')->execute(['id' => $contactId, 'client' => $clientId]); audit('client_contact_primary_changed', 'client_contact', (int)$contactId, ['client_id' => $clientId]); } else { $pdo->prepare('UPDATE client_contacts SET name = :name, email = :email, phone = :phone, is_primary = 0, notes = :notes WHERE id = :id AND client_id = :client')->execute(['name' => $result['name'], 'email' => $result['email'], 'phone' => $result['phone'], 'notes' => $result['notes'], 'id' => $contactId, 'client' => $clientId]); if ($result['is_primary']) $pdo->prepare('UPDATE client_contacts SET is_primary = 1 WHERE id = :id AND client_id = :client')->execute(['id' => $contactId, 'client' => $clientId]); audit('client_contact_updated', 'client_contact', (int)$contactId, ['client_id' => $clientId]); } $pdo->commit(); header('Location: /?route=client&id=' . $clientId . '&contact_updated=1'); exit; } catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $contactErrors[] = 'The contact action could not be completed.'; } } } elseif ($clientAction === 'technical') { require_permission('technical.manage'); $technicalData = $_POST; unset($technicalData['_csrf'], $technicalData['action'], $technicalData['category']); try { $command = (new \App\Domain\Credential\TechnicalInformationCommand())->validate(['client_id' => $clientId, 'category' => scalar_input($_POST['category'] ?? null), 'data' => $technicalData]); if (!$command['valid']) throw new InvalidArgumentException('Invalid technical information.'); $record = (new \App\Domain\Credential\TechnicalInformationRepository(db()))->upsert($clientId, $command['record']['category'], $command['record']['data'], (int)$user['id']); audit('technical_information_updated', 'technical_information', (int)$record['id'], ['client_id' => $clientId, 'category' => $record['category']]); header('Location: /?route=client&id=' . $clientId . '&technical_updated=1'); exit; } catch (Throwable $exception) { $contactErrors[] = 'Technical information could not be saved.'; } } else { require_permission('clients.manage'); $contact = validate_client_contact($_POST); $contactOld = $contact; $contactErrors = $contact['errors']; if ($contactErrors === []) { $pdo = db(); try { $pdo->beginTransaction(); if ($contact['is_primary']) { $pdo->prepare('UPDATE client_contacts SET is_primary = 0 WHERE client_id = :client')->execute(['client' => $clientId]); } $contactInsert = $pdo->prepare('INSERT INTO client_contacts (client_id, name, email, phone, is_primary) VALUES (:client, :name, :email, :phone, :primary)'); $contactInsert->execute(['client' => $clientId, 'name' => $contact['name'], 'email' => $contact['email'], 'phone' => $contact['phone'], 'primary' => $contact['is_primary'] ? 1 : 0]); $contactId = (int)$pdo->lastInsertId(); audit('client_contact_created', 'client_contact', $contactId, ['client_id' => $clientId]); $pdo->commit(); header('Location: /?route=client&id=' . $clientId . '&contact_created=1'); exit; } catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $contactErrors[] = 'The contact could not be created. Please try again.'; } } } } $contactsStmt = db()->prepare('SELECT id, client_id, name, email, phone, is_primary, notes FROM client_contacts WHERE client_id = :id ORDER BY is_primary DESC, name'); $contactsStmt->execute(['id' => $clientId]); $contacts = $contactsStmt->fetchAll(); $slaAgreement = null; if (can('sla.view') || can('sla.manage')) { $slaStmt = db()->prepare('SELECT * FROM sla_agreements WHERE client_id = :client LIMIT 1'); $slaStmt->execute(['client' => $clientId]); $slaAgreement = $slaStmt->fetch() ?: null; if ($slaAgreement && (int)$slaAgreement['enabled'] === 1) { $slaUsageStmt = db()->prepare("SELECT COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 THEN te.hours ELSE 0 END), 0) FROM time_entries te JOIN jobcards j ON j.id = te.jobcard_id WHERE j.client_id = :client AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided') AND ((:period_monthly = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (:period_annual = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR (:period_custom = 'custom' AND te.work_date BETWEEN COALESCE(:start_date, '1000-01-01') AND COALESCE(:end_date, CURDATE())))"); $slaUsageStmt->execute(['client' => $clientId, 'period_monthly' => $slaAgreement['period_type'], 'period_annual' => $slaAgreement['period_type'], 'period_custom' => $slaAgreement['period_type'], 'start_date' => $slaAgreement['start_date'], 'end_date' => $slaAgreement['end_date']]); $slaAgreement['used_hours'] = (float)$slaUsageStmt->fetchColumn(); $slaAgreement['remaining_hours'] = max(0.0, (float)$slaAgreement['allocated_hours'] - $slaAgreement['used_hours']); } } $credentialRows = []; if (can('credentials.view') || can('credentials.manage')) { $credentialStmt = db()->prepare('SELECT id, category, label, username, secret_ciphertext, notes FROM credentials WHERE client_id = :client AND is_active = 1 ORDER BY category, label'); $credentialStmt->execute(['client' => $clientId]); $credentialRows = $credentialStmt->fetchAll(); } render_header('Client details'); echo '
Jobcard history
'; echo '
← Back to clients View history

' . e($client['name']) . '

Client profile and support contacts.

' . e(ucfirst($client['status'])) . '
' . (isset($_GET['contact_created']) ? '
Contact added successfully.
' : '') . (isset($_GET['sla_updated']) ? '
SLA agreement updated.
' : '') . ($contactErrors ? '
' . e(implode(' ', $contactErrors)) . '
' : '') . ($slaErrors ? '
' . e(implode(' ', $slaErrors)) . '
' : '') . '

Support information

Email
' . e((string)($client['support_email'] ?? '—')) . '
Phone
' . e((string)($client['support_phone'] ?? '—')) . '
Preferred method
' . e((string)($client['preferred_contact_method'] ?? '—')) . '
Address
' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '

Contacts

'; if (!$contacts) echo '

No contacts recorded.

'; foreach ($contacts as $contact) { echo '
' . e($contact['name']) . ($contact['is_primary'] ? ' Primary' : '') . '
' . e((string)($contact['email'] ?? '')) . ' ' . e((string)($contact['phone'] ?? '')) . '
'; if (can('clients.manage')) { echo '
'; } echo '
'; } if (can('clients.manage')) echo '

Add contact

'; echo '
'; if (can('sla.view') || can('sla.manage')) { echo '

SLA agreement

'; if ($slaAgreement && (int)$slaAgreement['enabled'] === 1 && $user['role_name'] !== 'Technician') echo '
' . e(number_format((float)$slaAgreement['remaining_hours'], 2)) . ' SLA hours remaining
' . e(number_format((float)$slaAgreement['used_hours'], 2)) . ' of ' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours used in the current period.
'; if (can('sla.manage')) { echo '
'; } elseif ($slaAgreement && $user['role_name'] === 'Technician') echo '
' . e(number_format((float)($slaAgreement['used_hours'] ?? 0), 2)) . ' / ' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours
' . e(number_format((float)($slaAgreement['remaining_hours'] ?? $slaAgreement['allocated_hours']), 2)) . ' hours remaining
'; elseif ($slaAgreement) echo '
Type
' . e((string)($slaAgreement['agreement_type'] ?? '—')) . '
Allocation
' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours / ' . e($slaAgreement['period_type']) . '
Used
' . e(number_format((float)($slaAgreement['used_hours'] ?? 0), 2)) . ' hours
Remaining
' . e(number_format((float)($slaAgreement['remaining_hours'] ?? $slaAgreement['allocated_hours']), 2)) . ' hours
'; else echo '

No SLA agreement configured.

'; echo '
'; } if (can('credentials.view') || can('credentials.manage')) { echo '

Saved client information

'; if ($credentialErrors) echo '
' . e(implode(' ', $credentialErrors)) . '
'; if (isset($_GET['credential_created'])) echo '
Credential saved securely.
'; foreach ($credentialRows as $credentialRow) { echo '
' . e($credentialRow['label']) . '
Username: ' . e((string)($credentialRow['username'] ?? '—')) . ' · Password: ••••••••••••••••••••
'; if (can('credentials.view')) echo ' '; echo '
'; } echo '
'; } if (can('technical.view') || can('technical.manage')) { $technicalRows = (new \App\Domain\Credential\TechnicalInformationRepository(db()))->forClient($clientId); echo '

Technical information

'; foreach ($technicalRows as $technical) { $display = $technical['display']; echo '
' . e(ucfirst($technical['category'])) . '
' . e((string)$display['label']) . ($display['username'] ? ' · ' . e((string)$display['username']) : '') . '
' . nl2br(e((string)($display['notes'] ?? ''))) . '
'; } if (can('credentials.manage')) { echo '

Add client information

'; } echo '
'; } if (can('clients.manage')) echo '

Edit client

'; render_footer(); exit; } if ($route === 'clients') { require_permission('clients.view'); $errors = []; $old = ['name' => '', 'status' => 'active']; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { require_permission('clients.manage'); verify_csrf(); $validated = validate_client($_POST); $old = $validated; $errors = $validated['errors']; if ($errors === []) { $stmt = db()->prepare('INSERT INTO clients (name, status, created_by) VALUES (:name, :status, :created_by)'); $stmt->execute(['name' => $validated['name'], 'status' => $validated['status'], 'created_by' => $user['id']]); $clientId = (int)db()->lastInsertId(); audit('client_created', 'client', $clientId, ['name' => $validated['name']]); header('Location: /?route=clients&created=1'); exit; } } $search = trim(scalar_input($_GET['q'] ?? null)); $stmt = db()->prepare('SELECT id, name, status, support_email, support_phone, created_at FROM clients WHERE (:search = \'\' OR name LIKE :like_name OR support_email LIKE :like_email) ORDER BY name LIMIT 100'); if ($user['role_name'] === 'Technician') { $clientList = db()->prepare("SELECT DISTINCT c.id, c.name, c.status, c.support_email, c.support_phone, c.created_at FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user WHERE (:search = '' OR c.name LIKE :like_name OR c.support_email LIKE :like_email) ORDER BY c.name LIMIT 100"); $clientList->execute(['user' => $user['id'], 'search' => $search, 'like_name' => '%' . $search . '%', 'like_email' => '%' . $search . '%']); $clients = $clientList->fetchAll(); } else { $stmt->execute(['search' => $search, 'like_name' => '%' . $search . '%', 'like_email' => '%' . $search . '%']); $clients = $stmt->fetchAll(); } render_header('Clients'); echo '

Clients

Manage client records and support contacts.

'; if (can('clients.manage')) echo ''; echo '
'; if (isset($_GET['created'])) echo '
Client created successfully.
'; if (can('clients.manage')) { echo '

Create client

' . (isset($errors['name']) ? '
' . e($errors['name']) . '
' : '') . '
'; } echo '
'; if (!$clients) echo ''; foreach ($clients as $client) echo ''; echo '
ClientStatusSupport emailPhone
No clients found.
' . e($client['name']) . '' . e(ucfirst($client['status'])) . '' . e((string)($client['support_email'] ?? '—')) . '' . e((string)($client['support_phone'] ?? '—')) . '
'; render_footer(); exit; } if ($route === 'users') { require_permission('users.manage'); $userErrors = []; $userOld = ['name' => '', 'email' => '', 'role_id' => '']; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $userInput = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => '1', 'password' => $_POST['password'] ?? null]; $validatedUser = (new \App\Domain\User\UserRecord())->validateForCreate($userInput); $userOld = $validatedUser; $userErrors = $validatedUser['errors']; if (!$userErrors) { $roleCheck = db()->prepare('SELECT id FROM roles WHERE id = :id'); $roleCheck->execute(['id' => $validatedUser['role_id']]); if (!$roleCheck->fetchColumn()) $userErrors['role_id'] = 'Selected role does not exist.'; $emailCheck = db()->prepare('SELECT id FROM users WHERE email = :email'); $emailCheck->execute(['email' => $validatedUser['email']]); if ($emailCheck->fetchColumn()) $userErrors['email'] = 'A user with this email already exists.'; } if (!$userErrors) { $stmt = db()->prepare('INSERT INTO users (role_id, email, name, password_hash, is_active) VALUES (:role, :email, :name, :hash, 1)'); $stmt->execute(['role' => $validatedUser['role_id'], 'email' => $validatedUser['email'], 'name' => $validatedUser['name'], 'hash' => password_hash(scalar_input($_POST['password'] ?? null), PASSWORD_DEFAULT)]); $newUserId = (int)db()->lastInsertId(); audit('user_created', 'user', $newUserId, ['email' => $validatedUser['email'], 'role_id' => $validatedUser['role_id']]); header('Location: /?route=users&created=1'); exit; } } $roles = db()->query('SELECT id, name FROM roles ORDER BY name')->fetchAll(); $users = db()->query('SELECT u.id, u.name, u.email, u.is_active, u.last_login_at, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id ORDER BY u.name')->fetchAll(); render_header('Users'); echo '

Users

Create and review system accounts.

' . (isset($_GET['created']) ? '
User created successfully.
' : '') . ($userErrors ? '
' . e(implode(' ', $userErrors)) . '
' : '') . '
Use at least 12 characters with upper/lowercase, number and symbol.
'; foreach ($users as $listedUser) echo ''; echo '
NameEmailRoleStatusLast login
' . e($listedUser['name']) . '' . e($listedUser['email']) . '' . e($listedUser['role_name']) . '' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . '' . e((string)($listedUser['last_login_at'] ?? 'Never')) . '
'; render_footer(); exit; } if ($route === 'roles') { require_permission('roles.manage'); $roleErrors = []; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $action = scalar_input($_POST['action'] ?? 'create'); $roleId = filter_var(scalar_input($_POST['role_id'] ?? null), FILTER_VALIDATE_INT); try { $pdo = db(); $roleRecord = new \App\Domain\User\RoleRecord(); $matrix = new \App\Domain\User\PermissionMatrix(); $available = array_column($pdo->query('SELECT name FROM permissions ORDER BY name')->fetchAll(), 'name'); if ($action === 'create') { $validated = (new \App\Domain\User\RolePermissionService())->validateForCreate($_POST, $available); $roleErrors = $validated['errors']; if (!$roleErrors) { $stmt = $pdo->prepare('INSERT INTO roles (name, description) VALUES (:name, :description)'); $stmt->execute(['name' => $validated['name'], 'description' => $validated['description']]); $roleId = (int)$pdo->lastInsertId(); } } else { $roleStmt = $pdo->prepare('SELECT id, name, description FROM roles WHERE id = :id'); $roleStmt->execute(['id' => $roleId]); $role = $roleStmt->fetch(); if (!$role) $roleErrors['role'] = 'Role not found.'; else { $assignment = (new \App\Domain\User\RolePermissionService())->validateAssignment($role, is_array($_POST['permissions'] ?? null) ? $_POST['permissions'] : [], $available); $roleErrors = $assignment['errors']; if (!$roleErrors) { $pdo->beginTransaction(); $pdo->prepare('DELETE FROM role_permissions WHERE role_id = :role')->execute(['role' => $roleId]); $insert = $pdo->prepare('INSERT INTO role_permissions (role_id, permission_id) SELECT :role, id FROM permissions WHERE name = :name'); foreach ($assignment['permissions'] as $permission) $insert->execute(['role' => $roleId, 'name' => $permission]); $pdo->commit(); } } } if (!$roleErrors) { audit('role_updated', 'role', (int)$roleId); header('Location: /?route=roles&updated=1'); exit; } } catch (Throwable $exception) { if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack(); $roleErrors['role'] = 'Role changes could not be saved.'; } } $roles = db()->query('SELECT r.id, r.name, r.description, r.created_at, GROUP_CONCAT(p.name ORDER BY p.name SEPARATOR ", ") AS permission_names FROM roles r LEFT JOIN role_permissions rp ON rp.role_id = r.id LEFT JOIN permissions p ON p.id = rp.permission_id GROUP BY r.id, r.name, r.description, r.created_at ORDER BY r.name')->fetchAll(); $permissions = db()->query('SELECT name, description FROM permissions ORDER BY name')->fetchAll(); render_header('Roles and permissions'); echo '

Roles and permissions

Create custom roles and assign available permissions.

' . ($roleErrors ? '
' . e(implode(' ', $roleErrors)) . '
' : '') . (isset($_GET['updated']) ? '
Role changes saved.
' : '') . '

Create custom role

'; foreach ($roles as $role) { echo '

' . e($role['name']) . '

' . e((string)($role['description'] ?? '')) . '

'; $assigned = $role['permission_names'] ? explode(', ', $role['permission_names']) : []; foreach ($permissions as $permission) echo '
'; echo '
'; } render_footer(); exit; } if ($route === 'settings') { if (($user['role_name'] ?? '') !== 'Administrator') { http_response_code(403); exit('Forbidden'); } $settingsErrors = []; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $companyName = trim(scalar_input($_POST['company_name'] ?? null)); if ($companyName === '' || mb_strlen($companyName) > 190) $settingsErrors[] = 'Company name is required and must be 190 characters or fewer.'; $logo = $_FILES['logo'] ?? null; $newLogo = null; if (is_array($logo) && ($logo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { if (($logo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($logo['tmp_name'] ?? '')) $settingsErrors[] = 'Logo upload failed.'; else { $mime = (new finfo(FILEINFO_MIME_TYPE))->file($logo['tmp_name']); $extensions = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp']; if (!isset($extensions[$mime]) || (int)($logo['size'] ?? 0) > 5 * 1024 * 1024) $settingsErrors[] = 'Logo must be a PNG, JPG, GIF or WEBP image up to 5 MB.'; else $newLogo = bin2hex(random_bytes(16)) . '.' . $extensions[$mime]; } } if (!$settingsErrors) { $dir = __DIR__ . '/assets/branding'; if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) $settingsErrors[] = 'Branding storage is unavailable.'; if (!$settingsErrors && $newLogo !== null && !move_uploaded_file($logo['tmp_name'], $dir . '/' . $newLogo)) $settingsErrors[] = 'Logo could not be stored.'; if (!$settingsErrors) { $pdo = db(); $save = $pdo->prepare('INSERT INTO app_settings (setting_key, setting_value, updated_by) VALUES (:key, :value, :user) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_by = VALUES(updated_by)'); $save->execute(['key' => 'company_name', 'value' => $companyName, 'user' => $user['id']]); if ($newLogo !== null) $save->execute(['key' => 'logo_filename', 'value' => $newLogo, 'user' => $user['id']]); audit('branding_settings_updated', 'app_settings', 0, ['logo_updated' => $newLogo !== null]); header('Location: /?route=settings&updated=1'); exit; } } } $companyName = app_setting('company_name', 'JOBcard') ?: 'JOBcard'; $logoFile = app_setting('logo_filename'); render_header('Settings'); echo '

White-label settings

Customize the company name and logo shown across the workspace.

' . (isset($_GET['updated']) ? '
Branding settings saved.
' : '') . ($settingsErrors ? '
' . e(implode(' ', $settingsErrors)) . '
' : '') . '
PNG, JPG, GIF or WEBP up to 5 MB.
' . ($logoFile ? 'Current logo' : '') . '
'; render_footer(); exit; } if ($route === 'notifications') { require_permission('notifications.view'); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $notificationId = filter_var(scalar_input($_POST['notification_id'] ?? null), FILTER_VALIDATE_INT); try { if (!(new \App\Domain\Notification\NotificationQueue())->markRead(db(), (int)$user['id'], ['notification_id' => $notificationId])) { http_response_code(404); exit('Notification not found'); } audit('notification_read', 'notification', (int)$notificationId); header('Location: /?route=notifications&read=1'); exit; } catch (Throwable $exception) { http_response_code(400); exit('Invalid notification'); } } $stmt = db()->prepare('SELECT id, type, title, body, read_at, created_at FROM notifications WHERE user_id = :user ORDER BY created_at DESC LIMIT 100'); $stmt->execute(['user' => $user['id']]); $notifications = $stmt->fetchAll(); render_header('Notifications'); echo '

Notifications

'; foreach ($notifications as $notification) { echo '
' . e($notification['title']) . '' . e($notification['created_at']) . '

' . e((string)($notification['body'] ?? '')) . '

'; if (!$notification['read_at']) echo '
'; echo '
'; } render_footer(); exit; } if ($route === 'reports') { require_permission('reports.view'); $format = scalar_input($_GET['format'] ?? null); if ($format === 'csv') require_permission('reports.export'); try { $filters = \ReportFilters::fromArray($_GET); } catch (Throwable $exception) { http_response_code(400); exit('Invalid report filters'); } $detail = scalar_input($_GET['detail'] ?? null) === '1'; if ($detail) { $detailWhere = []; $detailParams = []; if ($filters->clientId !== null) { $detailWhere[] = 'c.id = ?'; $detailParams[] = $filters->clientId; } if ($filters->status !== null) { $detailWhere[] = 'j.status = ?'; $detailParams[] = $filters->status; } if ($filters->priority !== null) { $detailWhere[] = 'j.priority = ?'; $detailParams[] = $filters->priority; } if ($filters->dateFrom !== null) { $detailWhere[] = 'te.work_date >= ?'; $detailParams[] = $filters->dateFrom; } if ($filters->dateTo !== null) { $detailWhere[] = 'te.work_date <= ?'; $detailParams[] = $filters->dateTo; } $detailScope = ''; if ($user['role_name'] === 'Technician') { $detailScope = 'JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ?'; array_unshift($detailParams, (int)$user['id']); } $detailSql = 'SELECT j.reference_no, c.name AS client_name, j.created_at, j.status, j.work_requested, te.notes AS technician_notes, u.name AS technician_name, te.work_date, te.hours FROM jobcards j JOIN clients c ON c.id = j.client_id ' . $detailScope . ' LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = "time_entry" AND av.entity_id = te.id AND av.action = "time_entry_voided") LEFT JOIN users u ON u.id = te.technician_id' . ($detailWhere ? ' WHERE ' . implode(' AND ', $detailWhere) : '') . ' ORDER BY c.name, j.created_at DESC, te.work_date, te.id'; $detailStmt = db()->prepare($detailSql); $detailStmt->execute($detailParams); $detailRows = $detailStmt->fetchAll(); $detailHeaders = ['Jobcard', 'Client', 'Created', 'Status', 'Work requested', 'Technician notes / Work performed', 'Technician', 'Work date', 'Hours']; $detailData = array_map(static fn(array $row): array => [$row['reference_no'], $row['client_name'], $row['created_at'], $row['status'], $row['work_requested'], $row['technician_notes'], $row['technician_name'], $row['work_date'], $row['hours']], $detailRows); $totalReportHours = array_sum(array_map(static fn(array $row): float => (float)($row['hours'] ?? 0), $detailRows)); $detailData[] = ['TOTAL HOURS', '', '', '', '', '', '', '', round($totalReportHours, 2)]; if ($format === 'xls') { header('Content-Type: application/vnd.ms-excel; charset=UTF-8'); header('Content-Disposition: attachment; filename="jobcard-detail-report.xls"'); header('Cache-Control: no-store'); echo ''; foreach ($detailHeaders as $header) echo ''; echo ''; foreach ($detailData as $row) { echo ''; foreach ($row as $cell) echo ''; echo ''; } echo '
' . e($header) . '
' . e((string)($cell ?? '')) . '
'; exit; } if ($format === 'print') { header('Content-Type: text/html; charset=UTF-8'); header('Cache-Control: no-store'); echo (new \PrintReportRenderer())->render('Detailed jobcard report', $detailHeaders, $detailData); exit; } if ($format === null || $format === '') { render_header('Detailed report'); echo '

Detailed report

Each time entry is shown as its own line item.

'; foreach ($detailHeaders as $header) echo ''; echo ''; foreach ($detailData as $row) { echo ''; foreach ($row as $cell) echo ''; echo ''; } echo '
' . e($header) . '
' . nl2br(e((string)($cell ?? ''))) . '
'; render_footer(); exit; } } $where = []; $params = []; if ($filters->clientId !== null) { $where[] = 'c.id = ?'; $params[] = $filters->clientId; } if ($filters->status !== null) { $where[] = 'j.status = ?'; $params[] = $filters->status; } if ($filters->priority !== null) { $where[] = 'j.priority = ?'; $params[] = $filters->priority; } $reportScope = ''; if ($user['role_name'] === 'Technician') { $reportScope = 'JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ?'; $scopeParam = (int)$user['id']; } else { $scopeParam = null; } $hoursPredicates = ['NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = "time_entry" AND av.entity_id = te.id AND av.action = "time_entry_voided")']; $hoursParams = []; if ($user['role_name'] === 'Technician') { array_unshift($hoursPredicates, 'te.technician_id = ?'); $hoursParams[] = (int)$user['id']; } if ($filters->dateFrom !== null) { $hoursPredicates[] = 'te.work_date >= ?'; $hoursParams[] = $filters->dateFrom; } if ($filters->dateTo !== null) { $hoursPredicates[] = 'te.work_date <= ?'; $hoursParams[] = $filters->dateTo; } $reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(CASE WHEN ' . implode(' AND ', $hoursPredicates) . ' THEN te.hours ELSE 0 END), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id ' . $reportScope . ' LEFT JOIN time_entries te ON te.jobcard_id = j.id' . ($where ? ' WHERE ' . implode(' AND ', $where) : '') . ' GROUP BY c.id, c.name ORDER BY c.name'); $params = array_merge($hoursParams, $scopeParam === null ? [] : [$scopeParam], $params); $reportStmt->execute($params); $reportRows = $reportStmt->fetchAll(); $rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows); if ($user['role_name'] === 'Technician') { $reportClientsStmt = db()->prepare('SELECT DISTINCT c.id, c.name FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ? ORDER BY c.name'); $reportClientsStmt->execute([(int)$user['id']]); $reportClients = $reportClientsStmt->fetchAll(); } else { $reportClients = db()->query('SELECT id, name FROM clients ORDER BY name')->fetchAll(); } $filterQuery = http_build_query(array_filter(['client_id' => $filters->clientId, 'date_from' => $filters->dateFrom, 'date_to' => $filters->dateTo, 'technician_id' => $filters->technicianId, 'status' => $filters->status, 'priority' => $filters->priority], static fn($value): bool => $value !== null && $value !== '')); if ($format === 'print') { header('Content-Type: text/html; charset=UTF-8'); header('Cache-Control: no-store'); echo (new \PrintReportRenderer())->render('Hours per client', ['Client', 'Jobcards', 'Hours'], $rows); exit; } if ($format === 'csv') { $csv = (new CsvExporter())->export(['Client', 'Jobcards', 'Hours'], $rows, true); header('Content-Type: text/csv; charset=UTF-8'); header('Content-Disposition: attachment; filename="hours-per-client.csv"'); header('Cache-Control: no-store'); echo $csv; exit; } render_header('Reports'); echo '

Reports

Internal hours summary by client.

Print view'; if (can('reports.export')) echo 'Download detailed XLSDetailed print view'; echo '
'; if (!$reportRows) echo ''; foreach ($reportRows as $row) echo ''; echo '
ClientJobcardsHours
No report data available.
' . e($row['client_name']) . '' . (int)$row['jobcards'] . '' . e(number_format((float)$row['hours'], 2)) . '
'; render_footer(); exit; } if ($route === 'audit') { require_permission('audit.view'); $stmt = db()->query('SELECT a.id, a.action, a.entity_type, a.entity_id, a.metadata, a.ip_address, a.created_at, u.name AS user_name FROM audit_events a LEFT JOIN users u ON u.id = a.user_id ORDER BY a.created_at DESC, a.id DESC LIMIT 200'); render_header('Audit trail'); echo '

Audit trail

'; foreach ($stmt->fetchAll() as $event) echo ''; echo '
WhenUserActionEntityMetadata
' . e($event['created_at']) . '' . e((string)($event['user_name'] ?? 'System')) . '' . e($event['action']) . '' . e($event['entity_type']) . ' #' . (int)$event['entity_id'] . '' . e((string)($event['metadata'] ?? '')) . '
'; render_footer(); exit; } http_response_code(404); render_header('Not found'); ?>
Page not found.