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();
$mobileNav = $user && $user['role_name'] === 'Technician' ? 'Dashboard Jobcards Clients ' : '';
echo ' ' . ($user ? ' ' : '') . $mobileNav . '';
}
$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) ?>
120) { http_response_code(400); exit('Filter name is required.'); }
$filterJson = json_encode(['q' => trim(scalar_input($_POST['q'] ?? null)), 'status_filter' => scalar_input($_POST['status_filter'] ?? 'open')], JSON_THROW_ON_ERROR);
db()->prepare('INSERT INTO saved_filters (user_id, name, route, filter_json) VALUES (:user, :name, :route, :filters) ON DUPLICATE KEY UPDATE filter_json = VALUES(filter_json)')->execute(['user' => $user['id'], 'name' => $name, 'route' => 'jobcards', 'filters' => $filterJson]);
header('Location: /?route=jobcards&saved=1'); exit;
}
if ($route === 'search') {
require_permission('jobcards.view');
$query = trim(scalar_input($_GET['q'] ?? null));
if ($query === '') { header('Location: /?route=dashboard'); exit; }
$like = '%' . $query . '%'; $parts = []; $params = [];
$jobScope = $user['role_name'] === 'Technician' ? ' JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ?' : '';
if ($user['role_name'] === 'Technician') $params[] = $user['id'];
$parts[] = 'SELECT DISTINCT j.id, CONCAT("Jobcard ", j.reference_no) AS result_title, c.name AS result_context, CONCAT("/?route=jobcard&id=", j.id) AS result_url, "Jobcard" AS result_type FROM jobcards j JOIN clients c ON c.id = j.client_id' . $jobScope . ' WHERE (j.reference_no LIKE ? OR c.name LIKE ? OR j.work_requested LIKE ? OR j.technician_notes LIKE ?) AND j.status <> "closed"'; array_push($params, $like, $like, $like, $like);
$contactScope = $user['role_name'] === 'Technician' ? ' JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ?' : '';
if ($user['role_name'] === 'Technician') $params[] = $user['id'];
$parts[] = 'SELECT DISTINCT c.id, CONCAT("Client: ", c.name), c.support_email, CONCAT("/?route=client&id=", c.id), "Client" FROM clients c' . $contactScope . ' WHERE (c.name LIKE ? OR c.support_email LIKE ?)'; array_push($params, $like, $like);
if ($user['role_name'] !== 'Technician') { $parts[] = 'SELECT DISTINCT cc.id, CONCAT("Contact: ", cc.name), c.name, CONCAT("/?route=client&id=", c.id), "Contact" FROM client_contacts cc JOIN clients c ON c.id = cc.client_id WHERE (cc.name LIKE ? OR cc.email LIKE ? OR cc.phone LIKE ?)'; array_push($params, $like, $like, $like); }
$attachmentScope = $user['role_name'] === 'Technician' ? ' JOIN jobcard_assignments ja ON ja.jobcard_id = a.jobcard_id AND ja.user_id = ?' : '';
if ($user['role_name'] === 'Technician') $params[] = $user['id'];
$parts[] = 'SELECT DISTINCT a.id, CONCAT("Attachment: ", a.original_name), j.reference_no, CONCAT("/?route=attachment&id=", a.id), "Attachment" FROM attachments a JOIN jobcards j ON j.id = a.jobcard_id' . $attachmentScope . ' WHERE a.original_name LIKE ?'; $params[] = $like;
$searchStmt = db()->prepare(implode(' UNION ALL ', $parts) . ' ORDER BY result_type, result_title LIMIT 50'); $searchStmt->execute($params); $results = $searchStmt->fetchAll();
render_header('Search'); echo 'Search Results for “' . e($query) . '”
'; render_footer(); exit;
}
if ($route === 'dashboard') {
require_permission('dashboard.view');
$jobcardMetrics = db()->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 = [];
$assignedJobcards = [];
$inProgressJobcards = [];
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();
$workloadStmt = 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.status IN ('assigned', 'in_progress') ORDER BY FIELD(j.status, 'in_progress', 'assigned'), j.created_at ASC LIMIT 50");
$workloadStmt->execute(['user' => $user['id']]);
foreach ($workloadStmt->fetchAll() as $workItem) { if ($workItem['status'] === 'assigned') $assignedJobcards[] = $workItem; else $inProgressJobcards[] = $workItem; }
}
$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.
Urgent jobcards High and critical priority work assigned to you.
= count($urgentJobcards) ?> No urgent active jobcards.
'Assigned jobcards', 'items' => $assignedJobcards, 'description' => 'Newly assigned work waiting to be started.', 'border' => 'border-primary', 'badge' => 'primary'], ['title' => 'In-progress jobcards', 'items' => $inProgressJobcards, 'description' => 'Work currently being handled by you.', 'border' => 'border-info', 'badge' => 'info']] as $panel): ?>
= e($panel['title']) ?> = e($panel['description']) ?>
= count($panel['items']) ?> None currently.
'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 '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);
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 '' . 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 ' . e((string)($jobcard['internal_notes'] ?? '')) . ' Save 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 'Technician '; foreach ($technicians as $technician) echo '' . e($technician['name']) . ' '; echo '
'; } echo 'Date performed
Hours
Counts toward SLA
Technician notes / Work performed
Add time
'; }
echo '
Status ';
foreach ((new \App\Domain\Jobcard\StatusTransitionValidator())->allowedFrom($jobcard['status']) as $status) if (($status !== 'closed' || can('jobcards.close')) && ($jobcard['status'] !== 'closed' || $status === 'closed' || can('jobcards.reopen'))) 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.';
}
}
}
$search = trim(scalar_input($_GET['q'] ?? ($_SESSION['jobcard_filters']['q'] ?? null)));
$statusFilter = scalar_input($_GET['status_filter'] ?? ($_SESSION['jobcard_filters']['status_filter'] ?? null), 'open');
if (!in_array($statusFilter, ['open', 'in_progress', 'assigned', 'closed'], true)) $statusFilter = 'open';
$_SESSION['jobcard_filters'] = ['q' => $search, 'status_filter' => $statusFilter];
$page = max(1, (int)filter_var(scalar_input($_GET['page'] ?? null), FILTER_VALIDATE_INT));
$offset = ($page - 1) * 25;
$savedFilterStmt = db()->prepare('SELECT id, name, filter_json FROM saved_filters WHERE user_id = :user AND route = :route ORDER BY name'); $savedFilterStmt->execute(['user' => $user['id'], 'route' => 'jobcards']); $savedFilters = $savedFilterStmt->fetchAll();
$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 25 OFFSET ' . $offset);
$jobcardList->execute($params);
$jobcards = $jobcardList->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)) . '
';
echo '
Open jobcards In progress Assigned Closed
Search and filter
';
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;
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 '';
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 '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 '
Enabled
Agreement type
Allocated hours
Period '; foreach (['monthly','annual','custom'] as $period) echo '' . e(ucfirst($period)) . ' '; echo '
Start date
End date
Rollover enabled
SLA notes ' . e((string)($slaAgreement['notes'] ?? '')) . '
Save SLA
';
} 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 '
Reveal password ';
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 Name
Username
Password
Extra info
Save client information securely
'; }
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'] ?? ($_SESSION['client_filters']['q'] ?? null)));
$_SESSION['client_filters'] = ['q' => $search];
$page = max(1, (int)filter_var(scalar_input($_GET['page'] ?? null), FILTER_VALIDATE_INT)); $offset = ($page - 1) * 25;
$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 25 OFFSET ' . $offset);
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 25 OFFSET ' . $offset);
$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 === 'user_edit') {
require_permission('users.manage');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); exit('Edit requires POST'); }
verify_csrf();
$targetId = filter_var(scalar_input($_POST['user_id'] ?? null), FILTER_VALIDATE_INT);
$existingUsers = db()->query('SELECT u.id, u.name, u.email, u.role_id, u.is_active, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id')->fetchAll();
$target = null; foreach ($existingUsers as $candidate) if ((int)$candidate['id'] === (int)$targetId) { $target = $candidate; break; }
if (!$target) { http_response_code(404); exit('User not found'); }
$payload = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => isset($_POST['is_active']) ? '1' : '0'];
$service = new \App\Domain\User\UserAdminService();
$result = $service->validateForEdit((int)$targetId, $payload, $existingUsers);
if (trim(scalar_input($_POST['password'] ?? null)) !== '') { $passwordCheck = $service->validatePasswordReset(['id' => $targetId], $_POST['password']); if (!$passwordCheck['valid']) $result['errors'] = [...$result['errors'], ...$passwordCheck['errors']]; }
$roleCheck = db()->prepare('SELECT id FROM roles WHERE id = :id'); $roleCheck->execute(['id' => $result['role_id'] ?? 0]); if (!$roleCheck->fetchColumn()) $result['errors']['role_id'] = 'Selected role does not exist.';
if (!$result['errors']) {
$pdo = db(); $pdo->beginTransaction();
$pdo->prepare('UPDATE users SET name = :name, email = :email, role_id = :role, is_active = :active WHERE id = :id')->execute(['name' => $result['name'], 'email' => $result['email'], 'role' => $result['role_id'], 'active' => $result['is_active'] ? 1 : 0, 'id' => $targetId]);
if (trim(scalar_input($_POST['password'] ?? null)) !== '') $pdo->prepare('UPDATE users SET password_hash = :hash WHERE id = :id')->execute(['hash' => password_hash(scalar_input($_POST['password']), PASSWORD_DEFAULT), 'id' => $targetId]);
audit('user_updated', 'user', (int)$targetId, ['password_changed' => trim(scalar_input($_POST['password'] ?? null)) !== '']); $pdo->commit(); header('Location: /?route=users&updated=1'); exit;
}
$_SESSION['user_edit_errors'] = $result['errors']; header('Location: /?route=users&edit_user=' . (int)$targetId); exit;
}
if ($route === 'user_delete') {
if (($user['role_name'] ?? '') !== 'Administrator') { http_response_code(403); exit('Forbidden'); }
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); exit('Delete requires POST'); }
verify_csrf();
$targetId = filter_var(scalar_input($_POST['user_id'] ?? null), FILTER_VALIDATE_INT);
if (!$targetId || (int)$targetId === (int)$user['id']) { http_response_code(400); exit('User cannot be deleted.'); }
$targetStmt = db()->prepare('SELECT u.id, u.name, u.role_id, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = :id'); $targetStmt->execute(['id' => $targetId]); $target = $targetStmt->fetch();
$service = new \App\Domain\User\UserAdminService();
if (!$target || $service->isProtectedAdministrator($target)) { http_response_code(403); exit('Protected Administrator cannot be deleted.'); }
$linked = db()->prepare('SELECT (SELECT COUNT(*) FROM jobcard_assignments WHERE user_id = :id_a) + (SELECT COUNT(*) FROM time_entries WHERE technician_id = :id_b)'); $linked->execute(['id_a' => $targetId, 'id_b' => $targetId]);
if ((int)$linked->fetchColumn() > 0) { http_response_code(409); exit('User has assigned jobcards or time entries; deactivate the user instead.'); }
audit('user_deleted', 'user', (int)$targetId, ['name' => $target['name']]); db()->prepare('DELETE FROM users WHERE id = :id')->execute(['id' => $targetId]); header('Location: /?route=users&deleted=1'); 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();
$page = max(1, (int)filter_var(scalar_input($_GET['page'] ?? null), FILTER_VALIDATE_INT)); $offset = ($page - 1) * 25;
$users = db()->prepare('SELECT u.id, u.name, u.email, u.role_id, 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 LIMIT 25 OFFSET ' . $offset);
$users->execute(); $users = $users->fetchAll();
$editUser = null;
$editUserId = filter_var(scalar_input($_GET['edit_user'] ?? null), FILTER_VALIDATE_INT);
foreach ($users as $listed) if ($editUserId && (int)$listed['id'] === (int)$editUserId) { $editUser = $listed; break; }
if (isset($_SESSION['user_edit_errors'])) { $userErrors = (array)$_SESSION['user_edit_errors']; unset($_SESSION['user_edit_errors']); }
render_header('Users');
echo 'Users & roles Manage user accounts and their assigned roles.
Name Email Role Status Last login Actions ';
if (!$users) echo 'No users found. ';
foreach ($users as $account) echo '' . e($account['name']) . ' ' . e($account['email']) . ' ' . e($account['role_name']) . ' ' . e($account['is_active'] ? 'Active' : 'Inactive') . ' ' . e((string)($account['last_login_at'] ?? 'Never')) . ' Edit ' . (($user['role_name'] === 'Administrator' && (int)$account['id'] !== (int)$user['id'] && $account['role_name'] !== 'Administrator') ? 'Delete ' : '') . ' ';
echo '
';
if ($editUser) echo '';
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 Actions ';
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')) . ' Edit ' . (($user['role_name'] === 'Administrator' && (int)$listedUser['id'] !== (int)$user['id'] && $listedUser['role_name'] !== 'Administrator') ? 'Delete ' : '') . ' ';
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 === 'email_settings') {
if (($user['role_name'] ?? '') !== 'Administrator') { http_response_code(403); exit('Forbidden'); }
$emailErrors = [];
$emailSettingsStmt = db()->query('SELECT * FROM email_settings WHERE id = 1');
$emailSettings = $emailSettingsStmt->fetch() ?: ['smtp_port' => 587, 'smtp_encryption' => 'tls', 'assignment_enabled' => 1, 'status_enabled' => 1, 'sla_enabled' => 1, 'overdue_enabled' => 1];
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
$smtpHost = trim(scalar_input($_POST['smtp_host'] ?? null)); $smtpPort = filter_var(scalar_input($_POST['smtp_port'] ?? null), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 65535]]);
$fromEmail = trim(scalar_input($_POST['from_email'] ?? null)); $recipients = trim(scalar_input($_POST['notification_recipients'] ?? null));
if ($smtpHost !== '' && mb_strlen($smtpHost) > 190) $emailErrors[] = 'SMTP host is too long.';
if ($smtpPort === false) $emailErrors[] = 'SMTP port must be between 1 and 65535.';
if ($fromEmail !== '' && filter_var($fromEmail, FILTER_VALIDATE_EMAIL) === false) $emailErrors[] = 'From email must be valid.';
if ($recipients !== '') foreach (preg_split('/[\\s,;]+/', $recipients, -1, PREG_SPLIT_NO_EMPTY) as $recipient) if (filter_var($recipient, FILTER_VALIDATE_EMAIL) === false) $emailErrors[] = 'Every notification recipient must be a valid email address.';
$passwordCiphertext = $emailSettings['smtp_password_ciphertext'] ?? null; $smtpPassword = scalar_input($_POST['smtp_password'] ?? null);
if ($smtpPassword !== '') $passwordCiphertext = (new \App\Domain\Credential\CredentialVault())->encrypt($smtpPassword);
if (!$emailErrors) {
$save = db()->prepare('INSERT INTO email_settings (id, smtp_host, smtp_port, smtp_encryption, smtp_username, smtp_password_ciphertext, from_email, from_name, notification_recipients, assignment_enabled, status_enabled, sla_enabled, overdue_enabled, updated_by) VALUES (1, :host, :port, :encryption, :username, :password, :from_email, :from_name, :recipients, :assignment, :status, :sla, :overdue, :user) ON DUPLICATE KEY UPDATE smtp_host = VALUES(smtp_host), smtp_port = VALUES(smtp_port), smtp_encryption = VALUES(smtp_encryption), smtp_username = VALUES(smtp_username), smtp_password_ciphertext = VALUES(smtp_password_ciphertext), from_email = VALUES(from_email), from_name = VALUES(from_name), notification_recipients = VALUES(notification_recipients), assignment_enabled = VALUES(assignment_enabled), status_enabled = VALUES(status_enabled), sla_enabled = VALUES(sla_enabled), overdue_enabled = VALUES(overdue_enabled), updated_by = VALUES(updated_by)');
$save->execute(['host' => $smtpHost ?: null, 'port' => $smtpPort, 'encryption' => in_array($_POST['smtp_encryption'] ?? '', ['none','tls','ssl'], true) ? $_POST['smtp_encryption'] : 'tls', 'username' => trim(scalar_input($_POST['smtp_username'] ?? null)) ?: null, 'password' => $passwordCiphertext, 'from_email' => $fromEmail ?: null, 'from_name' => trim(scalar_input($_POST['from_name'] ?? null)) ?: null, 'recipients' => $recipients ?: null, 'assignment' => isset($_POST['assignment_enabled']) ? 1 : 0, 'status' => isset($_POST['status_enabled']) ? 1 : 0, 'sla' => isset($_POST['sla_enabled']) ? 1 : 0, 'overdue' => isset($_POST['overdue_enabled']) ? 1 : 0, 'user' => $user['id']]);
audit('email_settings_updated', 'email_settings', 1); header('Location: /?route=email_settings&updated=1'); exit;
}
$emailSettings = array_merge($emailSettings, $_POST);
}
render_header('Email settings');
echo '← Back to settings Email notification settings Configure SMTP delivery and which events generate email notifications.
' . (isset($_GET['updated']) ? 'Email settings saved.
' : '') . ($emailErrors ? '' . e(implode(' ', $emailErrors)) . '
' : '') . ''; 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)) . '
' : '') . '';
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'); }
$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 '' . e($header) . ' '; echo ' '; foreach ($detailData as $row) { echo ''; foreach ($row as $cell) echo '' . e((string)($cell ?? '')) . ' '; echo ' '; } echo '
'; 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 '' . e($header) . ' '; echo ' '; foreach ($detailData as $row) { echo ''; foreach ($row as $cell) echo '' . nl2br(e((string)($cell ?? ''))) . ' '; echo ' '; } echo '
'; 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 XLS Detailed print view ';
echo '
All clients '; foreach ($reportClients as $reportClient) echo 'clientId ?? 0) === (int)$reportClient['id'] ? ' selected' : '') . '>' . e($reportClient['name']) . ' '; 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');
$page = max(1, (int)filter_var(scalar_input($_GET['page'] ?? null), FILTER_VALIDATE_INT)); $offset = ($page - 1) * 50;
$stmt = db()->prepare('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 50 OFFSET ' . $offset); $stmt->execute();
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.