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 20:43:24 +02:00
require_once __DIR__ . '/../app/Domain/Client/ClientUpdateCommand.php' ;
2026-09-01 21:47:35 +02:00
require_once __DIR__ . '/../app/Domain/Client/ContactEditCommand.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' ;
2026-09-01 20:07:20 +02:00
require_once __DIR__ . '/../app/Domain/Jobcard/AssignmentValidator.php' ;
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryValidator.php' ;
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCommand.php' ;
2026-09-01 21:47:35 +02:00
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCorrectionCommand.php' ;
2026-09-01 20:07:20 +02:00
require_once __DIR__ . '/../app/Domain/SLA/SlaAgreement.php' ;
require_once __DIR__ . '/../app/Domain/SLA/SlaThresholdClassifier.php' ;
require_once __DIR__ . '/../app/Domain/User/PasswordPolicy.php' ;
require_once __DIR__ . '/../app/Domain/User/UserRecord.php' ;
2026-09-02 00:01:12 +02:00
require_once __DIR__ . '/../app/Domain/User/UserAdminService.php' ;
2026-09-01 21:47:35 +02:00
require_once __DIR__ . '/../app/Domain/User/RoleRecord.php' ;
require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php' ;
require_once __DIR__ . '/../app/Domain/User/RolePermissionService.php' ;
2026-09-01 19:34:41 +02:00
require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.php' ;
2026-09-01 20:43:24 +02:00
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php' ;
require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php' ;
2026-09-01 21:47:35 +02:00
require_once __DIR__ . '/../app/Domain/Reporting/ReportFilters.php' ;
require_once __DIR__ . '/../app/Domain/Reporting/ClientJobcardReport.php' ;
require_once __DIR__ . '/../app/Domain/Reporting/ClientHistoryReport.php' ;
require_once __DIR__ . '/../app/Domain/Reporting/TechnicianActivityReport.php' ;
require_once __DIR__ . '/../app/Domain/Reporting/SlaReport.php' ;
require_once __DIR__ . '/../app/Domain/Reporting/PrintReportRenderer.php' ;
2026-09-01 20:43:24 +02:00
require_once __DIR__ . '/../app/Domain/Credential/CredentialVault.php' ;
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php' ;
2026-09-01 21:47:35 +02:00
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformationRepository.php' ;
2026-09-01 20:43:24 +02:00
require_once __DIR__ . '/../app/Domain/Attachment/AttachmentValidator.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 ();
2026-09-01 23:16:34 +02:00
$brandName = app_setting ( 'company_name' , 'JOBcard' ) ?: 'JOBcard' ;
$logoFile = app_setting ( 'logo_filename' );
2026-09-01 23:20:02 +02:00
$brandMark = $logoFile ? '<img src="/assets/branding/' . e ( basename ( $logoFile )) . '" alt="' . e ( $brandName ) . '" class="brand-logo">' : '' ;
$brandLabel = $logoFile ? $brandMark : e ( $brandName );
2026-09-01 23:16:34 +02:00
echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . e ( $title ) . ' · ' . e ( $brandName ) . '</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>' ;
2026-09-01 18:54:47 +02:00
if ( $user ) {
2026-09-02 00:32:40 +02:00
echo '<nav class="navbar navbar-dark bg-primary"><div class="container-fluid"><a class="navbar-brand fw-bold d-flex align-items-center gap-2" href="/?route=dashboard">' . $brandLabel . '</a><form class="d-none d-md-flex nav-search mx-auto" method="get" action="/"><input type="hidden" name="route" value="search"><input class="form-control form-control-sm" name="q" placeholder="Search jobcards, clients, contacts..."><button class="btn btn-sm btn-light ms-2">Search</button></form><button class="navbar-toggler d-md-none" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button><span class="text-white small d-none d-lg-inline">' . 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 id="sidebarMenu" class="col-md-2 col-lg-2 border-end bg-white min-vh-100 p-3"><form class="d-md-none mb-3" method="get" action="/"><input type="hidden" name="route" value="search"><div class="input-group"><input class="form-control" name="q" placeholder="Search..."><button class="btn btn-primary">Go</button></div></form><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>' ;
2026-09-02 00:04:59 +02:00
if ( $user [ 'role_name' ] === 'Administrator' ) {
echo '<a class="nav-link sidebar-link d-flex justify-content-between align-items-center" data-bs-toggle="collapse" href="#adminMenu" role="button" aria-expanded="false" aria-controls="adminMenu">Admin <span>⌄</span></a><div class="collapse" id="adminMenu">' ;
if ( can ( 'users.manage' )) echo '<a class="nav-link sidebar-link ps-4" href="/?route=users">Users & roles</a>' ;
if ( can ( 'roles.manage' )) echo '<a class="nav-link sidebar-link ps-4" href="/?route=roles">Roles & permissions</a>' ;
2026-09-02 00:32:40 +02:00
echo '<a class="nav-link sidebar-link ps-4" href="/?route=settings">Settings</a><a class="nav-link sidebar-link ps-4" href="/?route=email_settings">Email settings</a>' ;
2026-09-02 00:04:59 +02:00
if ( can ( 'notifications.view' )) echo '<a class="nav-link sidebar-link ps-4" href="/?route=notifications">Notifications</a>' ;
if ( can ( 'audit.view' )) echo '<a class="nav-link sidebar-link ps-4" href="/?route=audit">Audit trail</a>' ;
echo '</div>' ;
} else {
if ( can ( 'users.manage' )) echo '<a class="nav-link sidebar-link" href="/?route=users">Users & roles</a>' ;
if ( can ( 'roles.manage' )) echo '<a class="nav-link sidebar-link" href="/?route=roles">Roles & permissions</a>' ;
if ( can ( 'notifications.view' )) echo '<a class="nav-link sidebar-link" href="/?route=notifications">Notifications</a>' ;
if ( can ( 'audit.view' )) echo '<a class="nav-link sidebar-link" href="/?route=audit">Audit trail</a>' ;
}
2026-09-01 18:54:47 +02:00
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 ();
2026-09-02 00:32:40 +02:00
$mobileNav = $user && $user [ 'role_name' ] === 'Technician' ? '<nav class="technician-bottom-nav"><a href="/?route=dashboard">Dashboard</a><a href="/?route=jobcards">Jobcards</a><a href="/?route=clients">Clients</a></nav>' : '' ;
echo '</main>' . ( $user ? '</div></div>' : '' ) . $mobileNav . '<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script><script>document.addEventListener("DOMContentLoaded",function(){document.querySelectorAll("form").forEach(function(form){form.addEventListener("submit",function(event){const action=form.querySelector("input[name=action]")?.value||form.getAttribute("action")||"";if(/delete|void/i.test(action)&&!window.confirm("Are you sure you want to continue?")){event.preventDefault();return;}const button=form.querySelector("button[type=submit],button:not([type])");if(button){button.disabled=true;button.dataset.originalText=button.textContent;button.textContent="Saving…";}});});let dirty=false;document.querySelectorAll("form").forEach(function(form){form.querySelectorAll("input:not([type=hidden]),textarea,select").forEach(function(field){field.addEventListener("change",function(){dirty=true;});});form.addEventListener("submit",function(){dirty=false;});});window.addEventListener("beforeunload",function(event){if(dirty){event.preventDefault();event.returnValue="";}});});</script></body></html>' ;
2026-09-01 18:54:47 +02:00
}
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 20:43:24 +02:00
if (( $_SERVER [ 'REQUEST_METHOD' ] ?? 'GET' ) !== 'POST' ) { http_response_code ( 405 ); exit ( 'Logout requires POST' ); }
2026-09-01 18:58:18 +02:00
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 ;
2026-09-01 20:43:24 +02:00
if (( $_SERVER [ 'REQUEST_METHOD' ] ?? 'GET' ) === 'POST' ) {
2026-09-01 18:54:47 +02:00
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();
2026-09-02 00:32:40 +02:00
if ($route === 'saved_filter_save') {
require_permission('jobcards.view');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); exit('Save requires POST'); }
verify_csrf(); $name = trim(scalar_input($_POST['filter_name'] ?? null));
if ($name === '' || mb_strlen($name) > 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);
2026-09-02 00:37:03 +02:00
$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 = ?' : '';
2026-09-02 00:32:40 +02:00
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 '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3">Search</h1><p class="text-muted mb-0">Results for “' . e($query) . '”</p></div></div><div class="card"><div class="list-group list-group-flush">'; if (!$results) echo '<div class="list-group-item text-muted">No matching records found.</div>'; foreach ($results as $result) echo '<a class="list-group-item list-group-item-action" href="' . e($result['result_url']) . '"><div class="d-flex justify-content-between"><strong>' . e($result['result_title']) . '</strong><span class="badge text-bg-secondary">' . e($result['result_type']) . '</span></div><div class="small text-muted">' . e((string)$result['result_context']) . '</div></a>'; echo '</div></div>'; render_footer(); exit;
}
2026-09-01 18:54:47 +02:00
if ($route === 'dashboard') {
require_permission('dashboard.view');
2026-09-01 20:07:20 +02:00
$jobcardMetrics = db()->query("SELECT SUM(status = 'new') AS new_count, SUM(status NOT IN ('completed','closed')) AS open_count FROM jobcards")->fetch();
2026-09-01 21:47:35 +02:00
$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();
2026-09-01 20:07:20 +02:00
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();
2026-09-01 21:47:35 +02:00
$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')");
2026-09-01 20:07:20 +02:00
$hoursStmt->execute(['user' => $user['id']]);
$hoursThisWeek = (float)$hoursStmt->fetchColumn();
2026-09-01 21:47:35 +02:00
$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");
2026-09-01 20:07:20 +02:00
$slaRowsStmt->execute(['user' => $user['id']]);
$slaRows = $slaRowsStmt->fetchAll();
}
2026-09-01 23:56:55 +02:00
$urgentJobcards = [];
2026-09-02 00:08:55 +02:00
$assignedJobcards = [];
$inProgressJobcards = [];
2026-09-01 23:56:55 +02:00
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();
2026-09-02 00:08:55 +02:00
$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; }
2026-09-01 23:56:55 +02:00
}
2026-09-01 20:07:20 +02:00
$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++;
2026-09-02 00:08:55 +02:00
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"><?= (int)($jobcardMetrics['new_count'] ?? 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"><?= (int)($jobcardMetrics['open_count'] ?? 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"><?= e(number_format($hoursThisWeek, 2)) ?></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"><?= $slaWarnings ?></div></div></div></div></div><div class="card mt-4"><div class="card-body"><h2 class="h5">Operations</h2><p class="mb-0 text-muted">Use Jobcards to manage assignments, status, technician notes and time entries. SLA threshold metrics will activate after period and rollover rules are configured.</p></div></div><?php if ($user['role_name'] === 'Technician') { ?><div class="card mt-4 border-warning"><div class="card-body"><div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="h5 mb-1">Urgent jobcards</h2><p class="text-muted mb-0">High and critical priority work assigned to you.</p></div><span class="badge text-bg-warning"><?= count($urgentJobcards) ?></span></div><?php if (!$urgentJobcards): ?><p class="text-muted mb-0">No urgent active jobcards.</p><?php else: ?><div class="list-group list-group-flush"><?php foreach ($urgentJobcards as $urgent): ?><a class="list-group-item list-group-item-action px-0 d-flex justify-content-between align-items-center" href="/?route=jobcard&id=<?= (int)$urgent['id'] ?>"><span><strong><?= e($urgent['reference_no']) ?></strong><span class="text-muted ms-2"><?= e($urgent['client_name']) ?></span></span><span><span class="badge text-bg-<?= $urgent['priority'] === 'critical' ? 'danger' : 'warning' ?> me-2"><?= e(ucfirst($urgent['priority'])) ?></span><?= e(ucwords(str_replace('_', ' ', $urgent['status']))) ?></span></a><?php endforeach; ?></div><?php endif; ?></div></div><?php } ?><?php if ($user['role_name'] === 'Technician'): ?><div class="row g-4 mt-1"><?php foreach ([['title' => '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): ?><div class="col-lg-6"><div class="card h-100 <?= $panel['border'] ?>"><div class="card-body"><div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="h5 mb-1"><?= e($panel['title']) ?></h2><p class="text-muted mb-0"><?= e($panel['description']) ?></p></div><span class="badge text-bg-<?= e($panel['badge']) ?>"><?= count($panel['items']) ?></span></div><?php if (!$panel['items']): ?><p class="text-muted mb-0">None currently.</p><?php else: ?><div class="list-group list-group-flush"><?php foreach ($panel['items'] as $item): ?><a class="list-group-item list-group-item-action px-0 d-flex justify-content-between align-items-center" href="/?route=jobcard&id=<?= (int)$item['id'] ?>"><span><strong><?= e($item['reference_no']) ?></strong><span class="text-muted ms-2"><?= e($item['client_name']) ?></span></span><span class="badge text-bg-<?= $item['priority'] === 'critical' ? 'danger' : ($item['priority'] === 'high' ? 'warning' : 'secondary') ?>">
2026-09-01 18:54:47 +02:00
}
$permissionByRoute = ['clients'=>'clients.view','jobcards'=>'jobcards.view','reports'=>'reports.view','users'=>'users.manage','audit'=>'audit.view'];
2026-09-01 20:43:24 +02:00
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;
}
2026-09-01 22:30:57 +02:00
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;
}
2026-09-01 21:47:35 +02:00
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; }
2026-09-02 00:15:59 +02:00
render_header('Client history'); echo '<div class="d-flex justify-content-between mb-4"><div><a href="/?route=client&id=' . (int)$clientId . '">← Back to client</a><h1 class="h3 mt-2">Client history</h1></div><a class="btn btn-outline-secondary" target="_blank" rel="noopener" href="/?route=client_history&id=' . (int)$clientId . '&format=print">Print view</a></div><div class="card"><div class="table-responsive"><table class="table"><thead><tr><th>Jobcard</th><th>From</th><th>To</th><th>Changed</th></tr></thead><tbody>'; foreach ($history as $row) echo '<tr><td>' . e($row['reference_no']) . '</td><td>' . e($row['from_status']) . '</td><td>' . e($row['to_status']) . '</td><td>' . e($row['changed_at']) . '</td></tr>'; echo '</tbody></table></div></div>'; render_footer(); exit;
2026-09-01 21:47:35 +02:00
}
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 '<h1 class="h3">Correct or void time entry</h1>' . ($errors ? '<div class="alert alert-danger">' . e(implode(' ', $errors)) . '</div>' : '') . '<form method="post" class="card card-body mb-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="correct"><div class="row g-2"><div class="col-md-3"><label class="form-label">Date</label><input class="form-control" type="date" name="work_date" value="' . e($entry['work_date']) . '"></div><div class="col-md-3"><label class="form-label">Hours</label><input class="form-control" type="number" step="0.01" name="hours" value="' . e((string)$entry['hours']) . '"></div><div class="col-md-6"><label class="form-label">Notes</label><input class="form-control" name="notes" value="' . e((string)($entry['notes'] ?? '')) . '"></div></div><button class="btn btn-primary mt-3">Save correction</button></form><form method="post" class="card card-body"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="void"><label class="form-label">Void reason</label><textarea class="form-control mb-2" name="reason" required></textarea><button class="btn btn-outline-danger">Void entry</button></form>'; render_footer(); exit;
}
2026-09-01 20:07:20 +02:00
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 = [];
2026-09-01 20:43:24 +02:00
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
2026-09-01 20:07:20 +02:00
verify_csrf();
$action = scalar_input($_POST['action'] ?? null);
if ($action === 'status') {
require_permission('jobcards.manage');
$to = scalar_input($_POST['status'] ?? null);
2026-09-01 22:59:40 +02:00
if ($to === 'closed') require_permission('jobcards.close');
2026-09-01 20:07:20 +02:00
$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.');
2026-09-01 23:42:42 +02:00
if ($locked['status'] === 'closed' && $to !== 'closed') require_permission('jobcards.reopen');
2026-09-01 20:07:20 +02:00
$now = date('Y-m-d H:i:s');
$completedAt = $to === 'completed' ? $now : ($locked['completed_at'] ?: null);
$closedAt = $to === 'closed' ? $now : ($locked['closed_at'] ?: null);
2026-09-01 23:42:42 +02:00
if ($locked['status'] === 'closed' && $to === 'in_progress') { $completedAt = null; $closedAt = null; }
2026-09-01 20:07:20 +02:00
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();
2026-09-01 21:47:35 +02:00
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 */ }
2026-09-01 20:07:20 +02:00
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();
2026-09-01 21:47:35 +02:00
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 */ }
2026-09-01 20:07:20 +02:00
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;
}
2026-09-01 20:43:24 +02:00
} 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.';
}
}
}
}
}
}
2026-09-01 20:07:20 +02:00
}
}
$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();
2026-09-01 21:47:35 +02:00
$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();
2026-09-01 20:43:24 +02:00
$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();
2026-09-01 20:07:20 +02:00
$totalHours = array_sum(array_map(static fn (array $entry): float => (float)$entry['hours'], $timeEntries));
render_header('Jobcard ' . $jobcard['reference_no']);
echo '<div class="d-flex justify-content-between align-items-start mb-4"><div><a href="/?route=jobcards" class="text-decoration-none">← Back to jobcards</a><h1 class="h3 mt-2 mb-1">' . e($jobcard['reference_no']) . '</h1><p class="text-muted mb-0">' . e($jobcard['client_name']) . '</p></div><span class="badge text-bg-primary">' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '</span></div>' . (isset($_GET['updated']) ? '<div class="alert alert-success">Jobcard updated.</div>' : '') . ($actionErrors ? '<div class="alert alert-danger">' . e(implode(' ', $actionErrors)) . '</div>' : '');
2026-09-01 23:22:45 +02:00
echo '<div class="row g-4"><div class="col-lg-8"><div class="card mb-4"><div class="card-body"><h2 class="h5">Work requested</h2><p class="mb-0">' . nl2br(e($jobcard['work_requested'])) . '</p></div></div>';
if (can('jobcards.internal_notes')) echo '<div class="card mb-4"><div class="card-body"><h2 class="h5">Internal notes</h2><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="notes"><textarea class="form-control mb-3" name="internal_notes" rows="4">' . e((string)($jobcard['internal_notes'] ?? '')) . '</textarea><button class="btn btn-primary">Save internal notes</button></form></div></div>';
echo '<div class="card"><div class="card-body"><div class="d-flex justify-content-between"><h2 class="h5">Time entries</h2><strong>' . e(number_format($totalHours, 2)) . ' hours</strong></div>';
2026-09-01 21:47:35 +02:00
foreach ($timeEntries as $entry) echo '<div class="border-bottom py-2"><strong>' . e($entry['technician_name']) . '</strong> · ' . e($entry['work_date']) . ' · ' . e(number_format((float)$entry['hours'], 2)) . 'h<div class="small text-muted">' . e((string)($entry['notes'] ?? '')) . '</div>' . (can('time_entries.record') ? '<a class="small" href="/?route=time_entry&id=' . (int)$entry['id'] . '">Correct or void</a>' : '') . '</div>';
2026-09-01 23:46:22 +02:00
if (can('time_entries.record')) { echo '<hr><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="time">'; if ($user['role_name'] !== 'Technician') { echo '<div class="col-md-4"><select class="form-select" name="technician_id" required><option value="">Technician</option>'; foreach ($technicians as $technician) echo '<option value="' . (int)$technician['id'] . '">' . e($technician['name']) . '</option>'; echo '</select></div>'; } echo '<div class="col-md-4"><label class="form-label">Date performed</label><input class="form-control" type="date" name="work_date" value="' . e(date('Y-m-d')) . '" required></div><div class="col-md-4"><label class="form-label">Hours</label><input class="form-control" type="number" step="0.01" min="0.01" name="hours" placeholder="0.00" required></div><div class="col-md-4 form-check pt-2"><input class="form-check-input" type="checkbox" name="counts_toward_sla" value="1" id="sla-time" checked><label class="form-check-label" for="sla-time">Counts toward SLA</label></div><div class="col-12"><label class="form-label">Technician notes / Work performed</label><textarea class="form-control" name="notes" rows="4" placeholder="Describe the work performed"></textarea></div><div class="col-12"><button class="btn btn-outline-primary">Add time</button></div></form>'; }
2026-09-01 20:07:20 +02:00
echo '</div></div></div><div class="col-lg-4"><div class="card mb-4"><div class="card-body"><h2 class="h5">Status</h2><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="status"><select class="form-select mb-2" name="status">';
2026-09-01 23:42:42 +02:00
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 '<option value="' . e($status) . '"' . ($status === $jobcard['status'] ? ' selected' : '') . '>' . e(ucwords(str_replace('_', ' ', $status))) . '</option>';
2026-09-01 20:07:20 +02:00
echo '</select><button class="btn btn-outline-primary w-100">Update status</button></form></div></div><div class="card"><div class="card-body"><h2 class="h5">Assigned technicians</h2>';
if (!$assigned) echo '<p class="text-muted">No technicians assigned.</p>'; foreach ($assigned as $assignment) echo '<div class="py-1">' . e($assignment['name']) . '</div>';
if (can('jobcards.assign')) { echo '<hr><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="assign"><select class="form-select mb-2" name="technician_id"><option value="">Select technician</option>'; foreach ($technicians as $technician) echo '<option value="' . (int)$technician['id'] . '">' . e($technician['name']) . '</option>'; echo '</select><button class="btn btn-outline-primary w-100">Assign</button></form>'; }
echo '</div></div></div></div>';
2026-09-01 20:43:24 +02:00
if (can('attachments.view') || can('attachments.manage')) {
echo '<div class="card mt-4"><div class="card-body"><h2 class="h5">Attachments</h2>';
if (!$attachments) echo '<p class="text-muted">No attachments.</p>';
foreach ($attachments as $attachment) echo '<div class="border-bottom py-2"><a href="/?route=attachment&id=' . (int)$attachment['id'] . '"><strong>' . e($attachment['original_name']) . '</strong></a> <span class="small text-muted">' . e($attachment['mime_type']) . ' · ' . e((string)$attachment['file_size']) . ' bytes · ' . ($attachment['client_visible'] ? 'Client approved' : 'Internal') . '</span></div>';
if (can('attachments.manage')) echo '<hr><form method="post" enctype="multipart/form-data" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="attachment"><div class="col-md-6"><input class="form-control" type="file" name="attachment" required></div><div class="col-md-3 form-check pt-2"><input class="form-check-input" type="checkbox" name="client_visible" value="1" id="attachment-visible"><label class="form-check-label" for="attachment-visible">Client visible</label></div><div class="col-md-3 form-check pt-2"><input class="form-check-input" type="checkbox" name="client_approved" value="1" id="attachment-approved"><label class="form-check-label" for="attachment-approved">Client approval confirmed</label></div><div class="col-12"><button class="btn btn-outline-primary">Upload attachment</button></div></form>';
echo '</div></div>';
}
2026-09-01 20:07:20 +02:00
render_footer(); exit;
}
2026-09-01 18:54:47 +02:00
if ($route === 'jobcards') {
require_permission('jobcards.view');
$errors = [];
2026-09-01 20:43:24 +02:00
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
2026-09-01 18:54:47 +02:00
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 20:43:24 +02:00
$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]);
2026-09-01 18:54:47 +02:00
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();
2026-09-01 20:07:20 +02:00
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']]);
2026-09-01 19:34:41 +02:00
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
}
}
2026-09-02 00:32:40 +02:00
$search = trim(scalar_input($_GET['q'] ?? ($_SESSION['jobcard_filters']['q'] ?? null)));
$statusFilter = scalar_input($_GET['status_filter'] ?? ($_SESSION['jobcard_filters']['status_filter'] ?? null), 'open');
2026-09-01 22:59:40 +02:00
if (!in_array($statusFilter, ['open', 'in_progress', 'assigned', 'closed'], true)) $statusFilter = 'open';
2026-09-02 00:32:40 +02:00
$_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();
2026-09-01 22:59:40 +02:00
$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']); }
2026-09-01 20:43:24 +02:00
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();
}
2026-09-02 00:32:40 +02:00
$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);
2026-09-01 22:59:40 +02:00
$jobcardList->execute($params);
$jobcards = $jobcardList->fetchAll();
2026-09-01 18:54:47 +02:00
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>';
2026-09-02 00:32:40 +02:00
echo '<form class="row g-2 mb-2" method="get"><input type="hidden" name="route" value="jobcards"><div class="col-md-6"><input class="form-control" name="q" value="' . e($search) . '" placeholder="Search reference, client or requested work"></div><div class="col-md-3"><select class="form-select" name="status_filter"><option value="open"' . ($statusFilter === 'open' ? ' selected' : '') . '>Open jobcards</option><option value="in_progress"' . ($statusFilter === 'in_progress' ? ' selected' : '') . '>In progress</option><option value="assigned"' . ($statusFilter === 'assigned' ? ' selected' : '') . '>Assigned</option><option value="closed"' . ($statusFilter === 'closed' ? ' selected' : '') . '>Closed</option></select></div><div class="col-md-3"><button class="btn btn-outline-primary w-100">Search and filter</button></div></form><div class="d-flex flex-wrap gap-2 mb-4"><a class="btn btn-sm btn-light" href="/?route=jobcards&status_filter=open">My open jobs</a><a class="btn btn-sm btn-light" href="/?route=jobcards&status_filter=assigned">Assigned</a><a class="btn btn-sm btn-light" href="/?route=jobcards&status_filter=in_progress">In progress</a><a class="btn btn-sm btn-light" href="/?route=jobcards&status_filter=closed">Closed</a><form method="post" action="/?route=saved_filter_save" class="d-flex gap-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="q" value="' . e($search) . '"><input type="hidden" name="status_filter" value="' . e($statusFilter) . '"><input class="form-control form-control-sm" name="filter_name" placeholder="Save filter as..." maxlength="120" required><button class="btn btn-sm btn-outline-secondary">Save</button></form>'; foreach ($savedFilters as $saved) { $savedData = json_decode((string)$saved['filter_json'], true); if (is_array($savedData)) echo '<a class="btn btn-sm btn-outline-secondary" href="/?route=jobcards&q=' . urlencode((string)($savedData['q'] ?? '')) . '&status_filter=' . urlencode((string)($savedData['status_filter'] ?? 'open')) . '">' . e($saved['name']) . '</a>'; } echo '</div>';
2026-09-01 18:54:47 +02:00
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>';
2026-09-01 20:07:20 +02:00
foreach ($jobcards as $jobcard) echo '<tr><td class="fw-semibold"><a class="text-decoration-none" href="/?route=jobcard&id=' . (int)$jobcard['id'] . '">' . e($jobcard['reference_no']) . '</a></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>';
2026-09-02 00:32:40 +02:00
echo '</tbody></table></div></div><div class="d-flex justify-content-between mt-3"><a class="btn btn-sm btn-outline-secondary' . ($page <= 1 ? ' disabled' : '') . '" href="/?route=jobcards&q=' . urlencode($search) . '&status_filter=' . urlencode($statusFilter) . '&page=' . max(1, $page - 1) . '">Previous</a><a class="btn btn-sm btn-outline-secondary' . (count($jobcards) < 25 ? ' disabled' : '') . '" href="/?route=jobcards&q=' . urlencode($search) . '&status_filter=' . urlencode($statusFilter) . '&page=' . ($page + 1) . '">Next</a></div>';
2026-09-01 18:54:47 +02:00
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'); }
2026-09-01 20:43:24 +02:00
if (!can_access_client($clientId)) { http_response_code(404); exit('Client not found'); }
2026-09-01 18:54:47 +02:00
$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];
2026-09-01 20:07:20 +02:00
$slaErrors = [];
2026-09-01 20:43:24 +02:00
$credentialErrors = [];
$revealedCredential = null;
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
2026-09-01 19:34:41 +02:00
verify_csrf();
2026-09-01 20:07:20 +02:00
$clientAction = scalar_input($_POST['action'] ?? null, 'contact');
2026-09-01 20:43:24 +02:00
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') {
2026-09-01 20:07:20 +02:00
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;
}
2026-09-01 20:43:24 +02:00
} 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.'; }
2026-09-01 21:47:35 +02:00
} 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.'; }
2026-09-01 20:07:20 +02:00
} 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.';
2026-09-01 19:34:41 +02:00
}
}
}
}
2026-09-01 21:47:35 +02:00
$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');
2026-09-01 18:54:47 +02:00
$contactsStmt->execute(['id' => $clientId]);
$contacts = $contactsStmt->fetchAll();
2026-09-01 20:07:20 +02:00
$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;
2026-09-01 23:16:34 +02:00
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']);
}
2026-09-01 20:07:20 +02:00
}
2026-09-01 20:43:24 +02:00
$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();
}
2026-09-01 18:54:47 +02:00
render_header('Client details');
2026-09-01 22:30:57 +02:00
echo '<div class="client-tabs" role="tablist"><button class="client-tab active" data-client-tab="overview">Overview</button><button class="client-tab" data-client-tab="contacts">Contacts</button><button class="client-tab" data-client-tab="sla">SLA</button><button class="client-tab" data-client-tab="technical">Client information</button><a class="client-tab" href="/?route=client_history&id=' . (int)$clientId . '">Jobcard history</a></div><script>document.addEventListener("DOMContentLoaded",function(){const tabs=[...document.querySelectorAll("[data-client-tab]")];const sections=[...document.querySelectorAll("[data-client-section]")];function activate(key){tabs.forEach(t=>t.classList.toggle("active",t.dataset.clientTab===key));sections.forEach(s=>s.classList.toggle("client-section-hidden",s.dataset.clientSection!==key));}tabs.forEach(t=>t.addEventListener("click",()=>activate(t.dataset.clientTab)));document.querySelectorAll(".js-reveal-credential").forEach(button=>button.addEventListener("click",async()=>{const id=button.dataset.credentialId;const target=document.querySelector(".credential-secret[data-credential-id=\""+id+"\"]");if(button.dataset.revealed==="1"){target.textContent="••••••••••••••••••••";button.textContent="Reveal password";button.dataset.revealed="0";return;}button.disabled=true;try{const body=new URLSearchParams({_csrf:document.querySelector("input[name=_csrf]").value,credential_id:id});const response=await fetch("/?route=credential_reveal",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body});const result=await response.json();if(!response.ok||!result.ok)throw new Error(result.error||"Unable to reveal password");target.textContent=result.secret;button.textContent="Hide password";button.dataset.revealed="1";}catch(error){window.alert(error.message);}finally{button.disabled=false;}}));activate("overview");});</script>';
2026-09-01 22:24:32 +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> <a href="/?route=client_history&id=' . (int)$clientId . '" class="text-decoration-none">View history</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>' : '') . (isset($_GET['sla_updated']) ? '<div class="alert alert-success">SLA agreement updated.</div>' : '') . ($contactErrors ? '<div class="alert alert-danger">' . e(implode(' ', $contactErrors)) . '</div>' : '') . ($slaErrors ? '<div class="alert alert-danger">' . e(implode(' ', $slaErrors)) . '</div>' : '') . '<div class="row g-4"><div class="col-lg-6"><div class="card h-100" data-client-section="overview"><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" data-client-section="contacts"><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>';
2026-09-01 21:47:35 +02:00
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>'; if (can('clients.manage')) { echo '<form method="post" class="d-inline me-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="contact_primary"><input type="hidden" name="contact_id" value="' . (int)$contact['id'] . '"><button class="btn btn-sm btn-link p-0">Set primary</button></form><form method="post" class="d-inline"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="contact_delete"><input type="hidden" name="contact_id" value="' . (int)$contact['id'] . '"><button class="btn btn-sm btn-link text-danger p-0">Delete</button></form><form method="post" class="row g-1 mt-1"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="contact_edit"><input type="hidden" name="contact_id" value="' . (int)$contact['id'] . '"><div class="col-md-3"><input class="form-control form-control-sm" name="name" value="' . e($contact['name']) . '" required></div><div class="col-md-3"><input class="form-control form-control-sm" type="email" name="email" value="' . e((string)($contact['email'] ?? '')) . '"></div><div class="col-md-3"><input class="form-control form-control-sm" name="phone" value="' . e((string)($contact['phone'] ?? '')) . '"></div><div class="col-md-3"><button class="btn btn-sm btn-outline-secondary">Save edit</button></div></form>'; } echo '</div>'; }
2026-09-01 20:07:20 +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()) . '"><input type="hidden" name="action" value="contact"><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>';
2026-09-01 20:07:20 +02:00
if (can('sla.view') || can('sla.manage')) {
2026-09-01 22:24:32 +02:00
echo '<div class="card mt-4" data-client-section="sla"><div class="card-body"><h2 class="h5">SLA agreement</h2>';
2026-09-01 23:54:16 +02:00
if ($slaAgreement && (int)$slaAgreement['enabled'] === 1 && $user['role_name'] !== 'Technician') echo '<div class="alert alert-primary"><strong>' . e(number_format((float)$slaAgreement['remaining_hours'], 2)) . ' SLA hours remaining</strong><div class="small">' . e(number_format((float)$slaAgreement['used_hours'], 2)) . ' of ' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours used in the current period.</div></div>';
2026-09-01 20:07:20 +02:00
if (can('sla.manage')) { echo '<form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="sla"><div class="col-md-3 form-check ms-2"><input class="form-check-input" type="checkbox" name="enabled" value="1" id="sla-enabled"' . (($slaAgreement['enabled'] ?? true) ? ' checked' : '') . '><label class="form-check-label" for="sla-enabled">Enabled</label></div><div class="col-md-4"><label class="form-label">Agreement type</label><input class="form-control" name="agreement_type" value="' . e((string)($slaAgreement['agreement_type'] ?? '')) . '"></div><div class="col-md-3"><label class="form-label">Allocated hours</label><input class="form-control" type="number" min="0" step="0.01" name="allocated_hours" value="' . e((string)($slaAgreement['allocated_hours'] ?? '0')) . '"></div><div class="col-md-3"><label class="form-label">Period</label><select class="form-select" name="period_type">'; foreach (['monthly','annual','custom'] as $period) echo '<option value="' . $period . '"' . (($slaAgreement['period_type'] ?? 'monthly') === $period ? ' selected' : '') . '>' . e(ucfirst($period)) . '</option>'; echo '</select></div><div class="col-md-3"><label class="form-label">Start date</label><input class="form-control" type="date" name="start_date" value="' . e((string)($slaAgreement['start_date'] ?? '')) . '"></div><div class="col-md-3"><label class="form-label">End date</label><input class="form-control" type="date" name="end_date" value="' . e((string)($slaAgreement['end_date'] ?? '')) . '"></div><div class="col-md-3 form-check pt-4"><input class="form-check-input" type="checkbox" name="rollover_enabled" value="1" id="sla-rollover"' . (($slaAgreement['rollover_enabled'] ?? false) ? ' checked' : '') . '><label class="form-check-label" for="sla-rollover">Rollover enabled</label></div><div class="col-12"><label class="form-label">SLA notes</label><textarea class="form-control" name="notes" rows="3">' . e((string)($slaAgreement['notes'] ?? '')) . '</textarea></div><div class="col-12"><button class="btn btn-primary">Save SLA</button></div></form>';
2026-09-01 23:54:16 +02:00
} elseif ($slaAgreement && $user['role_name'] === 'Technician') echo '<div class="text-center py-3"><div class="display-6 fw-semibold">' . e(number_format((float)($slaAgreement['used_hours'] ?? 0), 2)) . ' / ' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours</div><div class="text-muted">' . e(number_format((float)($slaAgreement['remaining_hours'] ?? $slaAgreement['allocated_hours']), 2)) . ' hours remaining</div></div>';
elseif ($slaAgreement) echo '<dl class="row mb-0"><dt class="col-sm-3">Type</dt><dd class="col-sm-9">' . e((string)($slaAgreement['agreement_type'] ?? '—')) . '</dd><dt class="col-sm-3">Allocation</dt><dd class="col-sm-9">' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours / ' . e($slaAgreement['period_type']) . '</dd><dt class="col-sm-3">Used</dt><dd class="col-sm-9">' . e(number_format((float)($slaAgreement['used_hours'] ?? 0), 2)) . ' hours</dd><dt class="col-sm-3">Remaining</dt><dd class="col-sm-9"><strong>' . e(number_format((float)($slaAgreement['remaining_hours'] ?? $slaAgreement['allocated_hours']), 2)) . ' hours</strong></dd></dl>';
2026-09-01 20:07:20 +02:00
else echo '<p class="text-muted mb-0">No SLA agreement configured.</p>';
echo '</div></div>';
}
2026-09-01 20:43:24 +02:00
if (can('credentials.view') || can('credentials.manage')) {
2026-09-01 22:24:32 +02:00
echo '<div class="card mt-4" data-client-section="technical"><div class="card-body"><h2 class="h5">Saved client information</h2>';
2026-09-01 20:43:24 +02:00
if ($credentialErrors) echo '<div class="alert alert-danger">' . e(implode(' ', $credentialErrors)) . '</div>';
if (isset($_GET['credential_created'])) echo '<div class="alert alert-success">Credential saved securely.</div>';
foreach ($credentialRows as $credentialRow) {
2026-09-01 22:30:57 +02:00
echo '<div class="border-bottom py-2"><strong>' . e($credentialRow['label']) . '</strong><div class="small text-muted">Username: ' . e((string)($credentialRow['username'] ?? '—')) . ' · Password: <span class="credential-secret" data-credential-id="' . (int)$credentialRow['id'] . '">••••••••••••••••••••</span></div>';
if (can('credentials.view')) echo ' <button type="button" class="btn btn-sm btn-link p-0 js-reveal-credential" data-credential-id="' . (int)$credentialRow['id'] . '">Reveal password</button>';
2026-09-01 20:43:24 +02:00
echo '</div>';
}
echo '</div></div>';
}
2026-09-01 21:47:35 +02:00
if (can('technical.view') || can('technical.manage')) {
$technicalRows = (new \App\Domain\Credential\TechnicalInformationRepository(db()))->forClient($clientId);
2026-09-01 22:24:32 +02:00
echo '<div class="card mt-4" data-client-section="technical"><div class="card-body"><h2 class="h5">Technical information</h2>';
2026-09-01 21:47:35 +02:00
foreach ($technicalRows as $technical) { $display = $technical['display']; echo '<div class="border-bottom py-2"><strong>' . e(ucfirst($technical['category'])) . '</strong><div>' . e((string)$display['label']) . ($display['username'] ? ' · ' . e((string)$display['username']) : '') . '</div><div class="small text-muted">' . nl2br(e((string)($display['notes'] ?? ''))) . '</div></div>'; }
2026-09-01 22:30:57 +02:00
if (can('credentials.manage')) { echo '<hr><h3 class="h6">Add client information</h3><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="credential"><input type="hidden" name="category" value="other"><div class="col-md-6"><label class="form-label">Name</label><input class="form-control" name="label" placeholder="e.g. Main server" required></div><div class="col-md-6"><label class="form-label">Username</label><input class="form-control" name="username" placeholder="Username"></div><div class="col-md-6"><label class="form-label">Password</label><input class="form-control" type="password" name="secret" placeholder="Password" required></div><div class="col-md-6"><label class="form-label">Extra info</label><textarea class="form-control" name="credential_notes" rows="5" placeholder="Extra information"></textarea></div><div class="col-12"><button class="btn btn-primary">Save client information securely</button></div></form>'; }
2026-09-01 21:47:35 +02:00
echo '</div></div>';
}
2026-09-01 22:24:32 +02:00
if (can('clients.manage')) echo '<div class="card mt-4" data-client-section="overview"><div class="card-body"><h2 class="h5">Edit client</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="client_update"><div class="col-md-6"><label class="form-label">Client name</label><input class="form-control" name="name" value="' . e($client['name']) . '" required></div><div class="col-md-3"><label class="form-label">Status</label><select class="form-select" name="status"><option value="active"' . ($client['status'] === 'active' ? ' selected' : '') . '>Active</option><option value="inactive"' . ($client['status'] === 'inactive' ? ' selected' : '') . '>Inactive</option></select></div><div class="col-md-3"><label class="form-label">Preferred contact</label><input class="form-control" name="preferred_contact_method" value="' . e((string)($client['preferred_contact_method'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">Support email</label><input class="form-control" type="email" name="support_email" value="' . e((string)($client['support_email'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">Support phone</label><input class="form-control" name="support_phone" value="' . e((string)($client['support_phone'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">Physical address</label><textarea class="form-control" name="physical_address" rows="3">' . e((string)($client['physical_address'] ?? '')) . '</textarea></div><div class="col-md-6"><label class="form-label">Postal address</label><textarea class="form-control" name="postal_address" rows="3">' . e((string)($client['postal_address'] ?? '')) . '</textarea></div><div class="col-12"><label class="form-label">General notes</label><textarea class="form-control" name="general_notes" rows="3">' . e((string)($client['general_notes'] ?? '')) . '</textarea></div><div class="col-12"><button class="btn btn-primary">Save client</button></div></form></div></div>';
2026-09-01 18:54:47 +02:00
render_footer();
exit;
}
if ($route === 'clients') {
require_permission('clients.view');
$errors = [];
$old = ['name' => '', 'status' => 'active'];
2026-09-01 20:43:24 +02:00
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
2026-09-01 18:54:47 +02:00
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-02 00:32:40 +02:00
$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);
2026-09-01 20:43:24 +02:00
if ($user['role_name'] === 'Technician') {
2026-09-02 00:32:40 +02:00
$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);
2026-09-01 20:43:24 +02:00
$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();
}
2026-09-01 18:54:47 +02:00
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>';
2026-09-02 00:32:40 +02:00
echo '</tbody></table></div></div><div class="d-flex justify-content-between mt-3"><a class="btn btn-sm btn-outline-secondary' . ($page <= 1 ? ' disabled' : '') . '" href="/?route=clients&q=' . urlencode($search) . '&page=' . max(1, $page - 1) . '">Previous</a><a class="btn btn-sm btn-outline-secondary' . (count($clients) < 25 ? ' disabled' : '') . '" href="/?route=clients&q=' . urlencode($search) . '&page=' . ($page + 1) . '">Next</a></div>';
2026-09-01 18:54:47 +02:00
render_footer();
exit;
}
2026-09-02 00:01:12 +02:00
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 id, name, email, role_id, is_active, 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;
}
2026-09-01 20:07:20 +02:00
if ($route === 'users') {
require_permission('users.manage');
$userErrors = [];
$userOld = ['name' => '', 'email' => '', 'role_id' => ''];
2026-09-01 20:43:24 +02:00
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
2026-09-01 20:07:20 +02:00
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();
2026-09-02 00:32:40 +02:00
$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();
2026-09-02 00:01:12 +02:00
$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']); }
2026-09-01 20:07:20 +02:00
render_header('Users');
2026-09-02 00:01:12 +02:00
if ($editUser) echo '<div class="card mb-4"><div class="card-body"><div class="d-flex justify-content-between"><h2 class="h5">Edit user</h2><a href="/?route=users">Cancel</a></div><form method="post" action="/?route=user_edit" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="user_id" value="' . (int)$editUser['id'] . '"><div class="col-md-4"><label class="form-label">Name</label><input class="form-control" name="name" value="' . e($editUser['name']) . '" required></div><div class="col-md-4"><label class="form-label">Email</label><input class="form-control" type="email" name="email" value="' . e($editUser['email']) . '" required></div><div class="col-md-4"><label class="form-label">Role</label><select class="form-select" name="role_id" required>'; foreach ($roles as $role) echo '<option value="' . (int)$role['id'] . '"' . ((int)$role['id'] === (int)$editUser['role_id'] ? ' selected' : '') . '>' . e($role['name']) . '</option>'; echo '</select></div><div class="col-md-4"><label class="form-label">New password <span class="text-muted">(optional)</span></label><input class="form-control" type="password" name="password" minlength="12"><div class="form-text">Leave blank to keep the current password.</div></div><div class="col-md-4 form-check pt-4"><input class="form-check-input" type="checkbox" name="is_active" value="1" id="edit-active"' . ($editUser['is_active'] ? ' checked' : '') . '><label class="form-check-label" for="edit-active">Active user</label></div><div class="col-12"><button class="btn btn-primary">Save user changes</button></div></form></div></div>';
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Users</h1><p class="text-muted mb-0">Create and review system accounts.</p></div><button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-user">New user</button></div>' . (isset($_GET['created']) ? '<div class="alert alert-success">User created successfully.</div>' : '') . ($userErrors ? '<div class="alert alert-danger">' . e(implode(' ', $userErrors)) . '</div>' : '') . '<div class="collapse mb-4" id="new-user"><div class="card"><div class="card-body"><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-4"><label class="form-label">Name</label><input class="form-control" name="name" required></div><div class="col-md-4"><label class="form-label">Email</label><input class="form-control" type="email" name="email" required></div><div class="col-md-4"><label class="form-label">Role</label><select class="form-select" name="role_id" required><option value="">Choose role</option>'; foreach ($roles as $role) echo '<option value="' . (int)$role['id'] . '">' . e($role['name']) . '</option>'; echo '</select></div><div class="col-md-6"><label class="form-label">Initial password</label><input class="form-control" type="password" name="password" minlength="12" required><div class="form-text">Use at least 12 characters with upper/lowercase, number and symbol.</div></div><div class="col-12"><button class="btn btn-primary">Create user</button></div></form></div></div></div><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th></tr></thead><tbody>';
foreach ($users as $listedUser) echo '<tr><td>' . e($listedUser['name']) . '</td><td>' . e($listedUser['email']) . '</td><td>' . e($listedUser['role_name']) . '</td><td>' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . '</td><td>' . e((string)($listedUser['last_login_at'] ?? 'Never')) . '</td><td><a class="btn btn-sm btn-outline-primary" href="/?route=users&edit_user=' . (int)$listedUser['id'] . '">Edit</a> ' . (($user['role_name'] === 'Administrator' && (int)$listedUser['id'] !== (int)$user['id'] && $listedUser['role_name'] !== 'Administrator') ? '<form method="post" action="/?route=user_delete" class="d-inline" onsubmit="return confirm(\'Delete this user permanently?\');"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="user_id" value="' . (int)$listedUser['id'] . '"><button class="btn btn-sm btn-outline-danger">Delete</button></form>' : '') . '</td></tr>';
2026-09-02 00:32:40 +02:00
echo '</tbody></table></div></div><div class="d-flex justify-content-between mt-3"><a class="btn btn-sm btn-outline-secondary' . ($page <= 1 ? ' disabled' : '') . '" href="/?route=users&page=' . max(1, $page - 1) . '">Previous</a><a class="btn btn-sm btn-outline-secondary' . (count($users) < 25 ? ' disabled' : '') . '" href="/?route=users&page=' . ($page + 1) . '">Next</a></div>'; render_footer(); exit;
2026-09-01 20:07:20 +02:00
}
2026-09-01 21:47:35 +02:00
if ($route === 'roles') {
require_permission('roles.manage');
$roleErrors = [];
2026-09-01 20:43:24 +02:00
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
2026-09-01 20:07:20 +02:00
verify_csrf();
2026-09-01 21:47:35 +02:00
$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.'; }
2026-09-01 20:07:20 +02:00
}
2026-09-01 21:47:35 +02:00
$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 '<div class="d-flex justify-content-between mb-4"><div><h1 class="h3">Roles and permissions</h1><p class="text-muted">Create custom roles and assign available permissions.</p></div></div>' . ($roleErrors ? '<div class="alert alert-danger">' . e(implode(' ', $roleErrors)) . '</div>' : '') . (isset($_GET['updated']) ? '<div class="alert alert-success">Role changes saved.</div>' : '') . '<div class="card mb-4"><div class="card-body"><h2 class="h5">Create custom role</h2><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="create"><div class="col-md-4"><input class="form-control" name="name" placeholder="Role name" required></div><div class="col-md-5"><input class="form-control" name="description" placeholder="Description"></div><div class="col-md-3"><button class="btn btn-primary">Create role</button></div></form></div></div>';
foreach ($roles as $role) { echo '<div class="card mb-3"><div class="card-body"><h2 class="h5">' . e($role['name']) . '</h2><p class="text-muted">' . e((string)($role['description'] ?? '')) . '</p><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="permissions"><input type="hidden" name="role_id" value="' . (int)$role['id'] . '"><div class="row">'; $assigned = $role['permission_names'] ? explode(', ', $role['permission_names']) : []; foreach ($permissions as $permission) echo '<div class="col-md-4 form-check"><input class="form-check-input" type="checkbox" name="permissions[]" value="' . e($permission['name']) . '"' . (in_array($permission['name'], $assigned, true) ? ' checked' : '') . '><label class="form-check-label">' . e($permission['name']) . '</label></div>'; echo '</div><button class="btn btn-sm btn-outline-primary mt-3">Save permissions</button></form></div></div>'; }
render_footer(); exit;
}
2026-09-02 00:32:40 +02:00
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 '<div class="mb-4"><a href="/?route=settings">← Back to settings</a><h1 class="h3 mt-2 mb-1">Email notification settings</h1><p class="text-muted mb-0">Configure SMTP delivery and which events generate email notifications.</p></div>' . (isset($_GET['updated']) ? '<div class="alert alert-success">Email settings saved.</div>' : '') . ($emailErrors ? '<div class="alert alert-danger">' . e(implode(' ', $emailErrors)) . '</div>' : '') . '<div class="card"><div class="card-body"><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">SMTP host</label><input class="form-control" name="smtp_host" value="' . e((string)($emailSettings['smtp_host'] ?? '')) . '"></div><div class="col-md-4"><label class="form-label">SMTP port</label><input class="form-control" type="number" name="smtp_port" value="' . e((string)($emailSettings['smtp_port'] ?? 587)) . '"></div><div class="col-md-4"><label class="form-label">Encryption</label><select class="form-select" name="smtp_encryption">'; foreach (['none','tls','ssl'] as $encryption) echo '<option value="' . $encryption . '"' . (($emailSettings['smtp_encryption'] ?? 'tls') === $encryption ? ' selected' : '') . '>' . strtoupper($encryption) . '</option>'; echo '</select></div><div class="col-md-4"><label class="form-label">SMTP username</label><input class="form-control" name="smtp_username" value="' . e((string)($emailSettings['smtp_username'] ?? '')) . '"></div><div class="col-md-4"><label class="form-label">SMTP password</label><input class="form-control" type="password" name="smtp_password" placeholder="Leave blank to keep current"></div><div class="col-md-6"><label class="form-label">From email</label><input class="form-control" type="email" name="from_email" value="' . e((string)($emailSettings['from_email'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">From name</label><input class="form-control" name="from_name" value="' . e((string)($emailSettings['from_name'] ?? '')) . '"></div><div class="col-12"><label class="form-label">Notification recipients</label><textarea class="form-control" name="notification_recipients" rows="3" placeholder="One or more email addresses separated by commas or new lines">' . e((string)($emailSettings['notification_recipients'] ?? '')) . '</textarea></div><div class="col-12"><h2 class="h6">Notification events</h2><div class="d-flex flex-wrap gap-4"><label><input type="checkbox" name="assignment_enabled"' . (!empty($emailSettings['assignment_enabled']) ? ' checked' : '') . '> Assignments</label><label><input type="checkbox" name="status_enabled"' . (!empty($emailSettings['status_enabled']) ? ' checked' : '') . '> Status changes</label><label><input type="checkbox" name="sla_enabled"' . (!empty($emailSettings['sla_enabled']) ? ' checked' : '') . '> SLA warnings</label><label><input type="checkbox" name="overdue_enabled"' . (!empty($emailSettings['overdue_enabled']) ? ' checked' : '') . '> Daily overdue summary</label></div></div><div class="col-12"><button class="btn btn-primary">Save email settings</button></div></form></div></div>'; render_footer(); exit;
}
2026-09-01 23:16:34 +02:00
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 '<div class="mb-4"><h1 class="h3 mb-1">White-label settings</h1><p class="text-muted mb-0">Customize the company name and logo shown across the workspace.</p></div>' . (isset($_GET['updated']) ? '<div class="alert alert-success">Branding settings saved.</div>' : '') . ($settingsErrors ? '<div class="alert alert-danger">' . e(implode(' ', $settingsErrors)) . '</div>' : '') . '<div class="card"><div class="card-body"><form method="post" enctype="multipart/form-data" class="row g-4"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-7"><label class="form-label">Company name</label><input class="form-control" name="company_name" value="' . e($companyName) . '" maxlength="190" required></div><div class="col-md-5"><label class="form-label">Logo</label><input class="form-control" type="file" name="logo" accept="image/png,image/jpeg,image/gif,image/webp"><div class="form-text">PNG, JPG, GIF or WEBP up to 5 MB.</div>' . ($logoFile ? '<img class="brand-preview mt-3" src="/assets/branding/' . e(basename($logoFile)) . '" alt="Current logo">' : '') . '</div><div class="col-12"><button class="btn btn-primary">Save branding</button></div></form></div></div>';
render_footer(); exit;
}
2026-09-01 21:47:35 +02:00
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 '<div class="d-flex justify-content-between mb-4"><h1 class="h3">Notifications</h1></div>'; foreach ($notifications as $notification) { echo '<div class="card mb-2"><div class="card-body"><div class="d-flex justify-content-between"><strong>' . e($notification['title']) . '</strong><small class="text-muted">' . e($notification['created_at']) . '</small></div><p class="mb-2">' . e((string)($notification['body'] ?? '')) . '</p>'; if (!$notification['read_at']) echo '<form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="notification_id" value="' . (int)$notification['id'] . '"><button class="btn btn-sm btn-outline-primary">Mark read</button></form>'; echo '</div></div>'; } render_footer(); exit;
2026-09-01 20:07:20 +02:00
}
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');
2026-09-01 21:47:35 +02:00
try { $filters = \ReportFilters::fromArray($_GET); } catch (Throwable $exception) { http_response_code(400); exit('Invalid report filters'); }
2026-09-01 22:59:40 +02:00
$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']); }
2026-09-01 23:22:45 +02:00
$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';
2026-09-01 22:59:40 +02:00
$detailStmt = db()->prepare($detailSql); $detailStmt->execute($detailParams); $detailRows = $detailStmt->fetchAll();
2026-09-01 23:22:45 +02:00
$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);
2026-09-01 23:16:34 +02:00
$totalReportHours = array_sum(array_map(static fn(array $row): float => (float)($row['hours'] ?? 0), $detailRows));
2026-09-01 23:22:45 +02:00
$detailData[] = ['TOTAL HOURS', '', '', '', '', '', '', '', round($totalReportHours, 2)];
2026-09-01 22:59:40 +02:00
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 '<html><head><meta charset="UTF-8"></head><body><table border="1"><thead><tr>'; foreach ($detailHeaders as $header) echo '<th>' . e($header) . '</th>'; echo '</tr></thead><tbody>'; foreach ($detailData as $row) { echo '<tr>'; foreach ($row as $cell) echo '<td>' . e((string)($cell ?? '')) . '</td>'; echo '</tr>'; } echo '</tbody></table></body></html>'; 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; }
2026-09-01 23:38:38 +02:00
if ($format === null || $format === '') {
render_header('Detailed report');
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Detailed report</h1><p class="text-muted mb-0">Each time entry is shown as its own line item.</p></div></div><div class="card"><div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr>'; foreach ($detailHeaders as $header) echo '<th>' . e($header) . '</th>'; echo '</tr></thead><tbody>'; foreach ($detailData as $row) { echo '<tr>'; foreach ($row as $cell) echo '<td>' . nl2br(e((string)($cell ?? ''))) . '</td>'; echo '</tr>'; } echo '</tbody></table></div></div>'; render_footer(); exit;
}
2026-09-01 22:59:40 +02:00
}
2026-09-01 22:47:06 +02:00
$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);
2026-09-01 21:47:35 +02:00
$reportRows = $reportStmt->fetchAll();
2026-09-01 19:34:41 +02:00
$rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows);
2026-09-01 23:04:51 +02:00
if ($user['role_name'] === 'Technician') {
2026-09-01 23:10:07 +02:00
$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']]);
2026-09-01 23:04:51 +02:00
$reportClients = $reportClientsStmt->fetchAll();
} else {
$reportClients = db()->query('SELECT id, name FROM clients ORDER BY name')->fetchAll();
}
2026-09-01 21:47:35 +02:00
$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; }
2026-09-01 19:34:41 +02:00
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');
2026-09-02 00:15:59 +02:00
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><a class="btn btn-outline-secondary me-2" target="_blank" rel="noopener" href="/?route=reports&format=print&' . e($filterQuery) . '">Print view</a>';
if (can('reports.export')) echo '<a class="btn btn-outline-primary me-2" href="/?route=reports&format=xls&detail=1&' . e($filterQuery) . '">Download detailed XLS</a><a class="btn btn-outline-secondary" target="_blank" rel="noopener" href="/?route=reports&detail=1&format=print&' . e($filterQuery) . '">Detailed print view</a>';
2026-09-01 22:59:40 +02:00
echo '</div><form class="row g-2 mb-3" method="get"><input type="hidden" name="route" value="reports"><div class="col-md-3"><select class="form-select" name="client_id"><option value="">All clients</option>'; foreach ($reportClients as $reportClient) echo '<option value="' . (int)$reportClient['id'] . '"' . ((int)($filters->clientId ?? 0) === (int)$reportClient['id'] ? ' selected' : '') . '>' . e($reportClient['name']) . '</option>'; echo '</select></div><div class="col-md-3"><input class="form-control" type="date" name="date_from" value="' . e((string)($filters->dateFrom ?? '')) . '"></div><div class="col-md-3"><input class="form-control" type="date" name="date_to" value="' . e((string)($filters->dateTo ?? '')) . '"></div><div class="col-auto"><button class="btn btn-outline-secondary">Apply filters</button></div></form><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>';
2026-09-01 19:34:41 +02:00
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 21:47:35 +02:00
if ($route === 'audit') {
require_permission('audit.view');
2026-09-02 00:32:40 +02:00
$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();
2026-09-01 21:47:35 +02:00
render_header('Audit trail'); echo '<h1 class="h3 mb-4">Audit trail</h1><div class="card"><div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>When</th><th>User</th><th>Action</th><th>Entity</th><th>Metadata</th></tr></thead><tbody>'; foreach ($stmt->fetchAll() as $event) echo '<tr><td>' . e($event['created_at']) . '</td><td>' . e((string)($event['user_name'] ?? 'System')) . '</td><td>' . e($event['action']) . '</td><td>' . e($event['entity_type']) . ' #' . (int)$event['entity_id'] . '</td><td><code>' . e((string)($event['metadata'] ?? '')) . '</code></td></tr>'; echo '</tbody></table></div></div>'; render_footer(); exit;
2026-09-01 18:54:47 +02:00
}
http_response_code(404); render_header('Not found'); ?><div class="alert alert-warning">Page not found.</div><?php render_footer();