2026-09-01 18:54:47 +02:00
<? php
declare ( strict_types = 1 );
require_once __DIR__ . '/../config/bootstrap.php' ;
require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php' ;
2026-09-01 19:34:41 +02:00
require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php' ;
require_once __DIR__ . '/../app/Domain/Client/ClientRecord.php' ;
2026-09-01 18:58:18 +02:00
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php' ;
2026-09-01 19:34:41 +02:00
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php' ;
require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.php' ;
2026-09-01 18:54:47 +02:00
2026-09-01 18:58:18 +02:00
ini_set ( 'session.use_strict_mode' , '1' );
$forwardedHttps = getenv ( 'TRUST_PROXY' ) === '1' && scalar_input ( $_SERVER [ 'HTTP_X_FORWARDED_PROTO' ] ?? '' ) === 'https' ;
session_set_cookie_params ([ 'httponly' => true , 'secure' => ! empty ( $_SERVER [ 'HTTPS' ]) || $forwardedHttps , 'samesite' => 'Lax' , 'path' => '/' ]);
2026-09-01 18:54:47 +02:00
session_start ();
function render_header ( string $title ) : void
{
$user = current_user ();
echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . e ( $title ) . ' · JOBcard</title><link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"><link href="/assets/app.css" rel="stylesheet"></head><body>' ;
if ( $user ) {
2026-09-01 18:58:18 +02:00
echo '<nav class="navbar navbar-dark bg-primary"><div class="container-fluid"><a class="navbar-brand fw-bold" href="/?route=dashboard">JOBcard</a><span class="text-white small">' . e ( $user [ 'name' ]) . ' · ' . e ( $user [ 'role_name' ]) . ' <form method="post" action="/?route=logout" class="d-inline"><input type="hidden" name="_csrf" value="' . e ( csrf_token ()) . '"><button class="btn btn-sm btn-light ms-2">Sign out</button></form></span></div></nav><div class="container-fluid"><div class="row"><aside class="col-md-2 col-lg-2 border-end bg-white min-vh-100 p-3"><nav class="nav flex-column gap-1"><a class="nav-link sidebar-link" href="/?route=dashboard">Dashboard</a>' ;
2026-09-01 18:54:47 +02:00
if ( can ( 'clients.view' )) echo '<a class="nav-link sidebar-link" href="/?route=clients">Clients</a>' ;
if ( can ( 'jobcards.view' )) echo '<a class="nav-link sidebar-link" href="/?route=jobcards">Jobcards</a>' ;
if ( can ( 'reports.view' )) echo '<a class="nav-link sidebar-link" href="/?route=reports">Reports</a>' ;
if ( can ( 'users.manage' )) echo '<a class="nav-link sidebar-link" href="/?route=users">Users & roles</a>' ;
if ( can ( 'audit.view' )) echo '<a class="nav-link sidebar-link" href="/?route=audit">Audit trail</a>' ;
echo '</nav></aside><main class="col-md-10 col-lg-10 p-3 p-lg-4">' ;
} else {
echo '<main class="container">' ;
}
}
function render_footer () : void
{
$user = current_user ();
echo '</main>' . ( $user ? '</div></div>' : '' ) . '<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script></body></html>' ;
}
2026-09-01 18:58:18 +02:00
$route = scalar_input ( $_GET [ 'route' ] ?? null , current_user () ? 'dashboard' : 'login' );
2026-09-01 18:54:47 +02:00
if ( $route === 'logout' ) {
2026-09-01 18:58:18 +02:00
if ( $_SERVER [ 'REQUEST_METHOD' ] !== 'POST' ) { http_response_code ( 405 ); exit ( 'Logout requires POST' ); }
verify_csrf ();
2026-09-01 18:54:47 +02:00
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' );
2026-09-01 18:58:18 +02:00
$stmt -> execute ([ 'email' => strtolower ( trim ( scalar_input ( $_POST [ 'email' ] ?? null ))) ]);
2026-09-01 18:54:47 +02:00
$user = $stmt -> fetch ();
2026-09-01 18:58:18 +02:00
if ( ! $user || ! $user [ 'is_active' ] || ! password_verify ( scalar_input ( $_POST [ 'password' ] ?? null ), $user [ 'password_hash' ])) {
2026-09-01 18:54:47 +02:00
$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' ); ?> <div class="card shadow-sm login-card"><div class="card-body p-4"><h1 class="h3 mb-1">JOBcard</h1><p class="text-muted mb-4">Sign in to the support workspace.</p><?php if ($error): ?><div class="alert alert-danger"><?= e($error) ?></div><?php endif; ?><form method="post"><input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>"><div class="mb-3"><label class="form-label" for="email">Email</label><input class="form-control" id="email" name="email" type="email" autocomplete="username" required></div><div class="mb-4"><label class="form-label" for="password">Password</label><input class="form-control" id="password" name="password" type="password" autocomplete="current-password" required></div><button class="btn btn-primary w-100">Sign in</button></form></div></div><?php render_footer(); exit;
}
$user = require_login();
if ($route === 'dashboard') {
require_permission('dashboard.view');
render_header('Dashboard'); ?><div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Dashboard</h1><p class="text-muted mb-0">Your operational overview.</p></div><span class="badge text-bg-primary"><?= e($user['role_name']) ?></span></div><div class="row g-3"><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">New jobcards</div><div class="display-6 fw-semibold">0</div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">Open jobcards</div><div class="display-6 fw-semibold">0</div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">Hours this week</div><div class="display-6 fw-semibold">0.0</div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">SLA warnings</div><div class="display-6 fw-semibold">0</div></div></div></div></div><div class="card mt-4"><div class="card-body"><h2 class="h5">Foundation ready</h2><p class="mb-0 text-muted">Authentication, role-aware navigation, CSRF protection, password hashing and audit logging are active. Client and jobcard modules will populate this dashboard in the next increments.</p></div></div><?php render_footer(); exit;
}
$permissionByRoute = ['clients'=>'clients.view','jobcards'=>'jobcards.view','reports'=>'reports.view','users'=>'users.manage','audit'=>'audit.view'];
if ($route === 'jobcards') {
require_permission('jobcards.view');
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_permission('jobcards.manage');
verify_csrf();
2026-09-01 19:34:41 +02:00
$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) {
2026-09-01 18:54:47 +02:00
$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) {
2026-09-01 18:58:18 +02:00
$year = (int)date('Y');
2026-09-01 19:34:41 +02:00
$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();
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.';
}
2026-09-01 18:54:47 +02:00
}
}
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
$jobcards = db()->query('SELECT 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 '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Jobcards</h1><p class="text-muted mb-0">Track requested work and operational status.</p></div>';
if (can('jobcards.manage')) echo '<button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-jobcard">New jobcard</button>';
echo '</div>';
if (isset($_GET['created'])) echo '<div class="alert alert-success">Jobcard created successfully.</div>';
if ($errors) echo '<div class="alert alert-danger">' . e(implode(' ', $errors)) . '</div>';
if (can('jobcards.manage')) { echo '<div class="collapse mb-4" id="new-jobcard"><div class="card"><div class="card-body"><h2 class="h5">Create jobcard</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-6"><label class="form-label" for="jobcard-client">Client</label><select class="form-select" id="jobcard-client" name="client_id" required><option value="">Choose client</option>'; foreach ($clients as $client) echo '<option value="' . (int)$client['id'] . '">' . e($client['name']) . '</option>'; echo '</select></div><div class="col-md-3"><label class="form-label" for="jobcard-priority">Priority</label><select class="form-select" id="jobcard-priority" name="priority"><option>low</option><option selected>normal</option><option>high</option><option>critical</option></select></div><div class="col-12"><label class="form-label" for="work-requested">Work requested</label><textarea class="form-control" id="work-requested" name="work_requested" rows="4" maxlength="10000" required></textarea></div><div class="col-12"><button class="btn btn-primary">Create jobcard</button></div></form></div></div></div>'; }
echo '<div class="card"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Reference</th><th>Client</th><th>Priority</th><th>Status</th><th>Work requested</th><th>Created</th></tr></thead><tbody>';
if (!$jobcards) echo '<tr><td colspan="6" class="text-center text-muted py-4">No jobcards found.</td></tr>';
foreach ($jobcards as $jobcard) echo '<tr><td class="fw-semibold">' . e($jobcard['reference_no']) . '</td><td>' . e($jobcard['client_name']) . '</td><td>' . e(ucfirst($jobcard['priority'])) . '</td><td>' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '</td><td class="text-truncate" style="max-width: 320px">' . e($jobcard['work_requested']) . '</td><td>' . e($jobcard['created_at']) . '</td></tr>';
echo '</tbody></table></div></div>';
render_footer(); exit;
}
if ($route === 'client') {
require_permission('clients.view');
2026-09-01 18:58:18 +02:00
$clientId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
2026-09-01 18:54:47 +02:00
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'); }
2026-09-01 19:34:41 +02:00
$contactErrors = [];
$contactOld = ['name' => '', 'email' => '', 'phone' => '', 'is_primary' => false];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_permission('clients.manage');
verify_csrf();
$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.';
}
}
}
2026-09-01 18:54:47 +02:00
$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();
render_header('Client details');
2026-09-01 19:34:41 +02:00
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><a href="/?route=clients" class="text-decoration-none">← Back to clients</a><h1 class="h3 mt-2 mb-1">' . e($client['name']) . '</h1><p class="text-muted mb-0">Client profile and support contacts.</p></div><span class="badge text-bg-' . ($client['status'] === 'active' ? 'success' : 'secondary') . '">' . e(ucfirst($client['status'])) . '</span></div>' . (isset($_GET['contact_created']) ? '<div class="alert alert-success">Contact added successfully.</div>' : '') . ($contactErrors ? '<div class="alert alert-danger">' . e(implode(' ', $contactErrors)) . '</div>' : '') . '<div class="row g-4"><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Support information</h2><dl class="row mb-0"><dt class="col-sm-5">Email</dt><dd class="col-sm-7">' . e((string)($client['support_email'] ?? '—')) . '</dd><dt class="col-sm-5">Phone</dt><dd class="col-sm-7">' . e((string)($client['support_phone'] ?? '—')) . '</dd><dt class="col-sm-5">Preferred method</dt><dd class="col-sm-7">' . e((string)($client['preferred_contact_method'] ?? '—')) . '</dd><dt class="col-sm-5">Address</dt><dd class="col-sm-7">' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '</dd></dl></div></div></div><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Contacts</h2>';
2026-09-01 18:54:47 +02:00
if (!$contacts) echo '<p class="text-muted mb-0">No contacts recorded.</p>';
foreach ($contacts as $contact) echo '<div class="border-bottom py-2"><div class="fw-semibold">' . e($contact['name']) . ($contact['is_primary'] ? ' <span class="badge text-bg-primary">Primary</span>' : '') . '</div><div class="small text-muted">' . e((string)($contact['email'] ?? '')) . ' ' . e((string)($contact['phone'] ?? '')) . '</div></div>';
2026-09-01 19:34:41 +02:00
if (can('clients.manage')) echo '<hr><h3 class="h6 mt-3">Add contact</h3><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-12"><input class="form-control" name="name" placeholder="Full name" value="' . e((string)$contactOld['name']) . '" required></div><div class="col-md-6"><input class="form-control" type="email" name="email" placeholder="Email" value="' . e((string)($contactOld['email'] ?? '')) . '"></div><div class="col-md-6"><input class="form-control" name="phone" placeholder="Phone" value="' . e((string)($contactOld['phone'] ?? '')) . '"></div><div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="is_primary" value="1" id="contact-primary"><label class="form-check-label" for="contact-primary">Primary contact</label></div><div class="col-12"><button class="btn btn-sm btn-outline-primary">Add contact</button></div></form>';
2026-09-01 18:54:47 +02:00
echo '</div></div></div></div>';
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;
}
}
2026-09-01 18:58:18 +02:00
$search = trim(scalar_input($_GET['q'] ?? null));
2026-09-01 18:54:47 +02:00
$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 '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Clients</h1><p class="text-muted mb-0">Manage client records and support contacts.</p></div>';
if (can('clients.manage')) echo '<button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-client">New client</button>';
echo '</div>';
if (isset($_GET['created'])) echo '<div class="alert alert-success">Client created successfully.</div>';
if (can('clients.manage')) {
echo '<div class="collapse mb-4" id="new-client"><div class="card"><div class="card-body"><h2 class="h5">Create client</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-8"><label class="form-label" for="client-name">Client/company name</label><input class="form-control" id="client-name" name="name" value="' . e((string)$old['name']) . '" required>' . (isset($errors['name']) ? '<div class="text-danger small">' . e($errors['name']) . '</div>' : '') . '</div><div class="col-md-4"><label class="form-label" for="client-status">Status</label><select class="form-select" id="client-status" name="status"><option value="active">Active</option><option value="inactive">Inactive</option></select></div><div class="col-12"><button class="btn btn-primary">Save client</button></div></form></div></div></div>';
}
echo '<form class="row g-2 mb-3"><input type="hidden" name="route" value="clients"><div class="col-sm-8 col-lg-5"><label class="visually-hidden" for="client-search">Search clients</label><input class="form-control" id="client-search" name="q" value="' . e($search) . '" placeholder="Search by client or support email"></div><div class="col-auto"><button class="btn btn-outline-secondary">Search</button></div></form><div class="card"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Client</th><th>Status</th><th>Support email</th><th>Phone</th></tr></thead><tbody>';
if (!$clients) echo '<tr><td colspan="4" class="text-center text-muted py-4">No clients found.</td></tr>';
foreach ($clients as $client) echo '<tr><td class="fw-semibold"><a href="/?route=client&id=' . (int)$client['id'] . '" class="text-decoration-none">' . e($client['name']) . '</a></td><td><span class="badge text-bg-' . ($client['status'] === 'active' ? 'success' : 'secondary') . '">' . e(ucfirst($client['status'])) . '</span></td><td>' . e((string)($client['support_email'] ?? '—')) . '</td><td>' . e((string)($client['support_phone'] ?? '—')) . '</td></tr>';
echo '</tbody></table></div></div>';
render_footer();
exit;
}
2026-09-01 19:34:41 +02:00
if ($route === 'reports') {
require_permission('reports.view');
$format = scalar_input($_GET['format'] ?? null);
if ($format === 'csv') require_permission('reports.export');
$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 '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Reports</h1><p class="text-muted mb-0">Internal hours summary by client.</p></div>';
if (can('reports.export')) echo '<a class="btn btn-outline-primary" href="/?route=reports&format=csv">Export CSV</a>';
echo '</div><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Client</th><th>Jobcards</th><th>Hours</th></tr></thead><tbody>';
if (!$reportRows) echo '<tr><td colspan="3" class="text-center text-muted py-4">No report data available.</td></tr>';
foreach ($reportRows as $row) echo '<tr><td>' . e($row['client_name']) . '</td><td>' . (int)$row['jobcards'] . '</td><td>' . e(number_format((float)$row['hours'], 2)) . '</td></tr>';
echo '</tbody></table></div></div>';
render_footer(); exit;
}
2026-09-01 18:54:47 +02:00
if (isset($permissionByRoute[$route])) {
require_permission($permissionByRoute[$route]);
render_header(ucfirst($route)); ?><div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1"><?= e(ucfirst($route)) ?></h1><p class="text-muted mb-0">This module is scaffolded for the next implementation phase.</p></div></div><div class="alert alert-info">The route is permission-protected and ready for its domain workflow.</div><?php render_footer(); exit;
}
http_response_code(404); render_header('Not found'); ?><div class="alert alert-warning">Page not found.</div><?php render_footer();