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 '
'; } 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'] !== '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'] === '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()')->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 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()'); $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 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.

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.

'clients.view','jobcards'=>'jobcards.view','reports'=>'reports.view','users'=>'users.manage','audit'=>'audit.view']; 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'] === '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(); 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(); 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; } } } $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 ORDER BY t.work_date DESC, t.id DESC'); $timeStmt->execute(['id' => $jobcardId]); $timeEntries = $timeStmt->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'])) . '

Work performed and notes

'; if (can('jobcards.internal_notes')) echo ''; 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'] ?? '')) . '
'; 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 '
'; render_footer(); exit; } if ($route === 'jobcards') { require_permission('jobcards.view'); $errors = []; if ($_SERVER['REQUEST_METHOD'] === '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'"); $clientCheck->execute(['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.'; } } } $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 ''; echo '
'; if (isset($_GET['created'])) echo '
Jobcard created successfully.
'; if ($errors) echo '
' . e(implode(' ', $errors)) . '
'; 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'); } $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 = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') { verify_csrf(); $clientAction = scalar_input($_POST['action'] ?? null, 'contact'); if ($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; } } 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 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; } render_header('Client details'); echo '
← Back to clients

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

Add contact

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

SLA agreement

'; if (can('sla.manage')) { echo '
'; } elseif ($slaAgreement) echo '
Type
' . e((string)($slaAgreement['agreement_type'] ?? '—')) . '
Allocation
' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours / ' . e($slaAgreement['period_type']) . '
'; else echo '

No SLA agreement configured.

'; echo '
'; } render_footer(); exit; } if ($route === 'clients') { require_permission('clients.view'); $errors = []; $old = ['name' => '', 'status' => 'active']; if ($_SERVER['REQUEST_METHOD'] === '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'); $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'] === '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 (false) { require_permission('users.manage'); $userErrors = []; if ($_SERVER['REQUEST_METHOD'] === '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); $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 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 === 'reports') { require_permission('reports.view'); $format = scalar_input($_GET['format'] ?? null); if ($format === 'csv') require_permission('reports.export'); if ($user['role_name'] === 'Technician') { $reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(te.hours), 0) AS hours 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 LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name'); $reportStmt->execute(['user' => $user['id']]); $reportRows = $reportStmt->fetchAll(); } else { $reportRows = db()->query('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(te.hours), 0) AS hours FROM clients c LEFT JOIN jobcards j ON j.client_id = c.id LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name')->fetchAll(); } $rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows); 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.

'; if (can('reports.export')) echo 'Export CSV'; 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 (isset($permissionByRoute[$route])) { require_permission($permissionByRoute[$route]); render_header(ucfirst($route)); ?>

This module is scaffolded for the next implementation phase.

The route is permission-protected and ready for its domain workflow.
Page not found.