true, 'secure' => !empty($_SERVER['HTTPS']) || $forwardedHttps, 'samesite' => 'Lax', 'path' => '/']);
session_start();
function render_header(string $title): void
{
$user = current_user();
echo '
' . e($title) . ' · JOBcard ';
if ($user) {
echo 'JOBcard ' . e($user['name']) . ' · ' . e($user['role_name']) . ' ';
if (can('clients.view')) echo '';
if (can('jobcards.view')) echo '';
if (can('reports.view')) echo '';
if (can('users.manage')) echo '';
if (can('roles.manage')) echo '';
if (can('notifications.view')) echo '';
if (can('audit.view')) echo '';
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.
= e($error) ?>
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();
}
$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.
= e($user['role_name']) ?> New jobcards
= (int)($jobcardMetrics['new_count'] ?? 0) ?>
Open jobcards
= (int)($jobcardMetrics['open_count'] ?? 0) ?>
Hours this week
= e(number_format($hoursThisWeek, 2)) ?>
SLA warnings
= $slaWarnings ?>
Operations Use Jobcards to manage assignments, status, technician notes and time entries. SLA threshold metrics will activate after period and rollover rules are configured.
'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 === '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 'Jobcard From To Changed '; foreach ($history as $row) echo '' . e($row['reference_no']) . ' ' . e($row['from_status']) . ' ' . e($row['to_status']) . ' ' . e($row['changed_at']) . ' '; echo '
'; 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);
$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.');
$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 ($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 '' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . ' ' . (isset($_GET['updated']) ? 'Jobcard updated.
' : '') . ($actionErrors ? '' . e(implode(' ', $actionErrors)) . '
' : '');
echo 'Work requested ' . nl2br(e($jobcard['work_requested'])) . '
Work performed and notes Technician notes / Work performed ' . e((string)($jobcard['technician_notes'] ?? '')) . ' ';
if (can('jobcards.internal_notes')) echo 'Internal notes ' . e((string)($jobcard['internal_notes'] ?? '')) . ' ';
echo 'Save notes
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 'Technician '; foreach ($technicians as $technician) echo '' . e($technician['name']) . ' '; echo '
'; } echo '
Counts toward SLA
Add time
'; }
echo '
Status ';
foreach ((new \App\Domain\Jobcard\StatusTransitionValidator())->allowedFrom($jobcard['status']) as $status) echo '' . e(ucwords(str_replace('_', ' ', $status))) . ' ';
echo ' Update status Assigned technicians ';
if (!$assigned) echo '
No technicians assigned.
'; foreach ($assigned as $assignment) echo '
' . e($assignment['name']) . '
';
if (can('jobcards.assign')) { echo '
Select technician '; foreach ($technicians as $technician) echo '' . e($technician['name']) . ' '; echo 'Assign '; }
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 '
Client visible
Client approval confirmed
Upload attachment
';
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.';
}
}
}
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();
}
if ($user['role_name'] === 'Technician') {
$jobcardList = db()->prepare('SELECT 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 JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user ORDER BY j.created_at DESC LIMIT 100');
$jobcardList->execute(['user' => $user['id']]);
$jobcards = $jobcardList->fetchAll();
} else {
$jobcards = db()->query('SELECT 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 ORDER BY j.created_at DESC LIMIT 100')->fetchAll();
}
render_header('Jobcards');
echo 'Jobcards Track requested work and operational status.
';
if (can('jobcards.manage')) echo '
New jobcard ';
echo '
';
if (isset($_GET['created'])) echo 'Jobcard created successfully.
';
if ($errors) echo '' . e(implode(' ', $errors)) . '
';
if (can('jobcards.manage')) { echo 'Create jobcard Client Choose client '; foreach ($clients as $client) echo '' . e($client['name']) . ' '; echo '
Priority low normal high critical
Work requested
Create jobcard
'; }
echo 'Reference Client Priority Status Work requested Created ';
if (!$jobcards) echo 'No jobcards found. ';
foreach ($jobcards as $jobcard) echo '' . 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']) . ' ';
echo '
';
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;
}
$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 '';
echo '' . 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'] ?? '—'))) . ' ';
if (can('sla.view') || can('sla.manage')) {
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']) . ' ' . e($credentialRow['category']) . ' Username: ' . e((string)($credentialRow['username'] ?? '—')) . ' · Secret: ' . ($revealedCredential && $revealedCredential['id'] === (int)$credentialRow['id'] ? '' . e($revealedCredential['secret']) . '' : '••••••••••••••••••••') . '
';
if (can('credentials.view') && !($revealedCredential && $revealedCredential['id'] === (int)$credentialRow['id'])) echo '
Reveal once ';
echo '
';
}
echo '
';
}
if (can('technical.view') || can('technical.manage')) {
$technicalRows = (new \App\Domain\Credential\TechnicalInformationRepository(db()))->forClient($clientId);
echo '';
}
if (can('clients.manage')) echo '';
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 '
New client ';
echo '
';
if (isset($_GET['created'])) echo 'Client created successfully.
';
if (can('clients.manage')) {
echo 'Create client Client/company name ' . (isset($errors['name']) ? '
' . e($errors['name']) . '
' : '') . '
Status Active Inactive
Save client
';
}
echo 'Search clients
Search
Client Status Support email Phone ';
if (!$clients) echo 'No clients found. ';
foreach ($clients as $client) echo '' . e($client['name']) . ' ' . e(ucfirst($client['status'])) . ' ' . e((string)($client['support_email'] ?? '—')) . ' ' . e((string)($client['support_phone'] ?? '—')) . ' ';
echo '
';
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.
New user ' . (isset($_GET['created']) ? 'User created successfully.
' : '') . ($userErrors ? '' . e(implode(' ', $userErrors)) . '
' : '') . 'Name Email Role Status Last login ';
foreach ($users as $listedUser) echo '' . e($listedUser['name']) . ' ' . e($listedUser['email']) . ' ' . e($listedUser['role_name']) . ' ' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . ' ' . e((string)($listedUser['last_login_at'] ?? 'Never')) . ' ';
echo '
'; 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.
' : '') . '';
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 '
' . e($permission['name']) . '
'; echo '
Save permissions '; }
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 '
Mark read '; 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'); }
$reportParams = ['client_id' => $filters->clientId ?? 0, 'status' => $filters->status ?? '', 'status_filter' => $filters->status ?? '', 'priority' => $filters->priority ?? '', 'priority_filter' => $filters->priority ?? '', 'date_from_a' => $filters->dateFrom ?? '', 'date_from_b' => $filters->dateFrom ?? '', 'date_to_a' => $filters->dateTo ?? '', 'date_to_b' => $filters->dateTo ?? ''];
$reportScope = $user['role_name'] === 'Technician' ? 'JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user_assigned' : '';
$reportParams['user_assigned'] = $user['id'];
$hoursCondition = $user['role_name'] === 'Technician' ? 'te.technician_id = :user' : '1 = 1';
$reportParams['user'] = $user['id'];
$reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(CASE WHEN ' . $hoursCondition . ' AND (:date_from_a = "" OR te.work_date >= :date_from_b) AND (:date_to_a = "" OR te.work_date <= :date_to_b) 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 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 (:client_id = 0 OR c.id = :client_filter) AND (:status = "" OR j.status = :status_filter) AND (:priority = "" OR j.priority = :priority_filter) GROUP BY c.id, c.name ORDER BY c.name');
$reportParams['client_filter'] = $filters->clientId ?? 0;
$reportStmt->execute($reportParams);
$reportRows = $reportStmt->fetchAll();
$rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows);
$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 '
Export CSV ';
echo '
Apply filters
Client Jobcards Hours ';
if (!$reportRows) echo 'No report data available. ';
foreach ($reportRows as $row) echo '' . e($row['client_name']) . ' ' . (int)$row['jobcards'] . ' ' . e(number_format((float)$row['hours'], 2)) . ' ';
echo '
';
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 When User Action Entity Metadata '; foreach ($stmt->fetchAll() as $event) echo '' . 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'] ?? '')) . ' '; echo '
'; render_footer(); exit;
}
http_response_code(404); render_header('Not found'); ?>Page not found.