feat: add global search pagination saved filters mobile ux and email settings

This commit is contained in:
Marco0300
2026-09-02 00:32:40 +02:00
parent 5f1b5dd1f0
commit 668ab374a6
6 changed files with 155 additions and 21 deletions
+9 -3
View File
@@ -8,7 +8,7 @@
--jc-border: #e8eaf2;
--jc-shadow: 0 14px 38px rgba(20, 28, 55, .08);
}
.brand-logo{width:48px;height:48px;object-fit:contain;border-radius:8px;background:#fff;padding:4px}.brand-preview{max-width:220px;max-height:90px;object-fit:contain;border:1px solid var(--jc-border);border-radius:10px;padding:8px;background:#fff}
.brand-logo{width:48px;height:48px;object-fit:contain;border-radius:8px;background:#fff;padding:4px}.brand-preview{max-width:220px;max-height:90px;object-fit:contain;border:1px solid var(--jc-border);border-radius:10px;padding:8px;background:#fff}.nav-search{max-width:520px;width:100%}.technician-bottom-nav{display:none}
* { box-sizing: border-box; }
body { background: var(--jc-bg); color: var(--jc-ink); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: -.01em; }
a { color: var(--jc-primary); }
@@ -45,8 +45,14 @@ main { min-width: 0; }
@media (max-width: 767.98px) {
.desktop-table { display: none; }
.mobile-card { display: block; }
.sidebar-link { padding: .55rem .7rem; }
.client-tabs { overflow-x: auto; flex-wrap: nowrap; scrollbar-width: thin; }
.sidebar-link { padding: .75rem .8rem; min-height:44px; }
.btn, .form-control, .form-select { min-height:44px; }
.technician-bottom-nav { display:flex; position:fixed; z-index:1030; left:0; right:0; bottom:0; background:#fff; border-top:1px solid var(--jc-border); box-shadow:0 -8px 24px rgba(20,28,55,.12); padding:.45rem; justify-content:space-around; }
.technician-bottom-nav a { color:var(--jc-primary); font-weight:700; text-decoration:none; padding:.55rem .8rem; }
body:has(.technician-bottom-nav) { padding-bottom:74px; }
.nav-search { max-width:none; }
main { padding-bottom:1.5rem; }
.client-tab { white-space: nowrap; padding: .62rem .8rem; }
}
@media (min-width: 768px) { .mobile-card { display: none; } }
+77 -15
View File
@@ -49,7 +49,7 @@ function render_header(string $title): void
$brandLabel = $logoFile ? $brandMark : e($brandName);
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>';
if ($user) {
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><span class="text-white small">' . e($user['name']) . ' · ' . e($user['role_name']) . ' <form method="post" action="/?route=logout" class="d-inline"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><button class="btn btn-sm btn-light ms-2">Sign out</button></form></span></div></nav><div class="container-fluid"><div class="row"><aside class="col-md-2 col-lg-2 border-end bg-white min-vh-100 p-3"><nav class="nav flex-column gap-1"><a class="nav-link sidebar-link" href="/?route=dashboard">Dashboard</a>';
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>';
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>';
@@ -57,7 +57,7 @@ function render_header(string $title): void
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>';
echo '<a class="nav-link sidebar-link ps-4" href="/?route=settings">Settings</a>';
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>';
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>';
@@ -75,7 +75,8 @@ function render_header(string $title): void
function render_footer(): void
{
$user = current_user();
echo '</main>' . ($user ? '</div></div>' : '') . '<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script></body></html>';
$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>';
}
$route = scalar_input($_GET['route'] ?? null, current_user() ? 'dashboard' : 'login');
@@ -113,6 +114,33 @@ if ($route === 'login') {
}
$user = require_login();
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);
$contactScope = $user['role_name'] === 'Technician' ? ' JOIN jobcards j ON j.client_id = c.client_id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ?' : '';
if ($user['role_name'] === 'Technician') $params[] = $user['id'];
$parts[] = 'SELECT DISTINCT c.id, CONCAT("Client: ", c.name), c.support_email, CONCAT("/?route=client&id=", c.id), "Client" FROM clients c' . $contactScope . ' WHERE (c.name LIKE ? OR c.support_email LIKE ?)'; array_push($params, $like, $like);
if ($user['role_name'] !== 'Technician') { $parts[] = 'SELECT DISTINCT cc.id, CONCAT("Contact: ", cc.name), c.name, CONCAT("/?route=client&id=", c.id), "Contact" FROM client_contacts cc JOIN clients c ON c.id = cc.client_id WHERE (cc.name LIKE ? OR cc.email LIKE ? OR cc.phone LIKE ?)'; array_push($params, $like, $like, $like); }
$attachmentScope = $user['role_name'] === 'Technician' ? ' JOIN jobcard_assignments ja ON ja.jobcard_id = a.jobcard_id AND ja.user_id = ?' : '';
if ($user['role_name'] === 'Technician') $params[] = $user['id'];
$parts[] = 'SELECT DISTINCT a.id, CONCAT("Attachment: ", a.original_name), j.reference_no, CONCAT("/?route=attachment&id=", a.id), "Attachment" FROM attachments a JOIN jobcards j ON j.id = a.jobcard_id' . $attachmentScope . ' WHERE a.original_name LIKE ?'; $params[] = $like;
$searchStmt = db()->prepare(implode(' UNION ALL ', $parts) . ' ORDER BY result_type, result_title LIMIT 50'); $searchStmt->execute($params); $results = $searchStmt->fetchAll();
render_header('Search'); echo '<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;
}
if ($route === 'dashboard') {
require_permission('dashboard.view');
$jobcardMetrics = db()->query("SELECT SUM(status = 'new') AS new_count, SUM(status NOT IN ('completed','closed')) AS open_count FROM jobcards")->fetch();
@@ -409,9 +437,13 @@ if ($route === 'jobcards') {
}
}
}
$search = trim(scalar_input($_GET['q'] ?? null));
$statusFilter = scalar_input($_GET['status_filter'] ?? null, 'open');
$search = trim(scalar_input($_GET['q'] ?? ($_SESSION['jobcard_filters']['q'] ?? null)));
$statusFilter = scalar_input($_GET['status_filter'] ?? ($_SESSION['jobcard_filters']['status_filter'] ?? null), 'open');
if (!in_array($statusFilter, ['open', 'in_progress', 'assigned', 'closed'], true)) $statusFilter = 'open';
$_SESSION['jobcard_filters'] = ['q' => $search, 'status_filter' => $statusFilter];
$page = max(1, (int)filter_var(scalar_input($_GET['page'] ?? null), FILTER_VALIDATE_INT));
$offset = ($page - 1) * 25;
$savedFilterStmt = db()->prepare('SELECT id, name, filter_json FROM saved_filters WHERE user_id = :user AND route = :route ORDER BY name'); $savedFilterStmt->execute(['user' => $user['id'], 'route' => 'jobcards']); $savedFilters = $savedFilterStmt->fetchAll();
$conditions = [];
$params = [];
if ($search !== '') { $conditions[] = '(j.reference_no LIKE ? OR c.name LIKE ? OR j.work_requested LIKE ?)'; $like = '%' . $search . '%'; array_push($params, $like, $like, $like); }
@@ -425,7 +457,7 @@ if ($route === 'jobcards') {
} else {
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
}
$jobcardList = db()->prepare('SELECT DISTINCT j.id, j.reference_no, j.priority, j.status, j.work_requested, j.created_at, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id' . $scopeJoin . ' WHERE ' . implode(' AND ', $conditions) . ' ORDER BY j.created_at DESC LIMIT 100');
$jobcardList = db()->prepare('SELECT DISTINCT j.id, j.reference_no, j.priority, j.status, j.work_requested, j.created_at, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id' . $scopeJoin . ' WHERE ' . implode(' AND ', $conditions) . ' ORDER BY j.created_at DESC LIMIT 25 OFFSET ' . $offset);
$jobcardList->execute($params);
$jobcards = $jobcardList->fetchAll();
render_header('Jobcards');
@@ -434,12 +466,12 @@ if ($route === 'jobcards') {
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>';
echo '<form class="row g-2 mb-4" 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>';
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>';
if (can('jobcards.manage')) { echo '<div class="collapse mb-4" id="new-jobcard"><div class="card"><div class="card-body"><h2 class="h5">Create jobcard</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-6"><label class="form-label" for="jobcard-client">Client</label><select class="form-select" id="jobcard-client" name="client_id" required><option value="">Choose client</option>'; foreach ($clients as $client) echo '<option value="' . (int)$client['id'] . '">' . e($client['name']) . '</option>'; echo '</select></div><div class="col-md-3"><label class="form-label" for="jobcard-priority">Priority</label><select class="form-select" id="jobcard-priority" name="priority"><option>low</option><option selected>normal</option><option>high</option><option>critical</option></select></div><div class="col-12"><label class="form-label" for="work-requested">Work requested</label><textarea class="form-control" id="work-requested" name="work_requested" rows="4" maxlength="10000" required></textarea></div><div class="col-12"><button class="btn btn-primary">Create jobcard</button></div></form></div></div></div>'; }
echo '<div class="card"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Reference</th><th>Client</th><th>Priority</th><th>Status</th><th>Work requested</th><th>Created</th></tr></thead><tbody>';
if (!$jobcards) echo '<tr><td colspan="6" class="text-center text-muted py-4">No jobcards found.</td></tr>';
foreach ($jobcards as $jobcard) echo '<tr><td class="fw-semibold"><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>';
echo '</tbody></table></div></div>';
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>';
render_footer(); exit;
}
@@ -647,10 +679,12 @@ if ($route === 'clients') {
exit;
}
}
$search = trim(scalar_input($_GET['q'] ?? null));
$stmt = db()->prepare('SELECT id, name, status, support_email, support_phone, created_at FROM clients WHERE (:search = \'\' OR name LIKE :like_name OR support_email LIKE :like_email) ORDER BY name LIMIT 100');
$search = trim(scalar_input($_GET['q'] ?? ($_SESSION['client_filters']['q'] ?? null)));
$_SESSION['client_filters'] = ['q' => $search];
$page = max(1, (int)filter_var(scalar_input($_GET['page'] ?? null), FILTER_VALIDATE_INT)); $offset = ($page - 1) * 25;
$stmt = db()->prepare('SELECT id, name, status, support_email, support_phone, created_at FROM clients WHERE (:search = \'\' OR name LIKE :like_name OR support_email LIKE :like_email) ORDER BY name LIMIT 25 OFFSET ' . $offset);
if ($user['role_name'] === 'Technician') {
$clientList = db()->prepare("SELECT DISTINCT c.id, c.name, c.status, c.support_email, c.support_phone, c.created_at FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user WHERE (:search = '' OR c.name LIKE :like_name OR c.support_email LIKE :like_email) ORDER BY c.name LIMIT 100");
$clientList = db()->prepare('SELECT DISTINCT c.id, c.name, c.status, c.support_email, c.support_phone, c.created_at FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user WHERE (:search = \'\' OR c.name LIKE :like_name OR c.support_email LIKE :like_email) ORDER BY c.name LIMIT 25 OFFSET ' . $offset);
$clientList->execute(['user' => $user['id'], 'search' => $search, 'like_name' => '%' . $search . '%', 'like_email' => '%' . $search . '%']);
$clients = $clientList->fetchAll();
} else {
@@ -668,7 +702,7 @@ if ($route === 'clients') {
echo '<form class="row g-2 mb-3"><input type="hidden" name="route" value="clients"><div class="col-sm-8 col-lg-5"><label class="visually-hidden" for="client-search">Search clients</label><input class="form-control" id="client-search" name="q" value="' . e($search) . '" placeholder="Search by client or support email"></div><div class="col-auto"><button class="btn btn-outline-secondary">Search</button></div></form><div class="card"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Client</th><th>Status</th><th>Support email</th><th>Phone</th></tr></thead><tbody>';
if (!$clients) echo '<tr><td colspan="4" class="text-center text-muted py-4">No clients found.</td></tr>';
foreach ($clients as $client) echo '<tr><td class="fw-semibold"><a href="/?route=client&id=' . (int)$client['id'] . '" class="text-decoration-none">' . e($client['name']) . '</a></td><td><span class="badge text-bg-' . ($client['status'] === 'active' ? 'success' : 'secondary') . '">' . e(ucfirst($client['status'])) . '</span></td><td>' . e((string)($client['support_email'] ?? '—')) . '</td><td>' . e((string)($client['support_phone'] ?? '—')) . '</td></tr>';
echo '</tbody></table></div></div>';
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>';
render_footer();
exit;
}
@@ -732,7 +766,9 @@ if ($route === 'users') {
}
}
$roles = db()->query('SELECT id, name FROM roles ORDER BY name')->fetchAll();
$users = db()->query('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')->fetchAll();
$page = max(1, (int)filter_var(scalar_input($_GET['page'] ?? null), FILTER_VALIDATE_INT)); $offset = ($page - 1) * 25;
$users = db()->prepare('SELECT u.id, u.name, u.email, u.role_id, u.is_active, u.last_login_at, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id ORDER BY u.name LIMIT 25 OFFSET ' . $offset);
$users->execute(); $users = $users->fetchAll();
$editUser = null;
$editUserId = filter_var(scalar_input($_GET['edit_user'] ?? null), FILTER_VALIDATE_INT);
foreach ($users as $listed) if ($editUserId && (int)$listed['id'] === (int)$editUserId) { $editUser = $listed; break; }
@@ -741,7 +777,7 @@ if ($route === 'users') {
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>';
echo '</tbody></table></div></div>'; render_footer(); exit;
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;
}
if ($route === 'roles') {
@@ -771,6 +807,31 @@ if ($route === 'roles') {
render_footer(); exit;
}
if ($route === 'email_settings') {
if (($user['role_name'] ?? '') !== 'Administrator') { http_response_code(403); exit('Forbidden'); }
$emailErrors = [];
$emailSettingsStmt = db()->query('SELECT * FROM email_settings WHERE id = 1');
$emailSettings = $emailSettingsStmt->fetch() ?: ['smtp_port' => 587, 'smtp_encryption' => 'tls', 'assignment_enabled' => 1, 'status_enabled' => 1, 'sla_enabled' => 1, 'overdue_enabled' => 1];
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
$smtpHost = trim(scalar_input($_POST['smtp_host'] ?? null)); $smtpPort = filter_var(scalar_input($_POST['smtp_port'] ?? null), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 65535]]);
$fromEmail = trim(scalar_input($_POST['from_email'] ?? null)); $recipients = trim(scalar_input($_POST['notification_recipients'] ?? null));
if ($smtpHost !== '' && mb_strlen($smtpHost) > 190) $emailErrors[] = 'SMTP host is too long.';
if ($smtpPort === false) $emailErrors[] = 'SMTP port must be between 1 and 65535.';
if ($fromEmail !== '' && filter_var($fromEmail, FILTER_VALIDATE_EMAIL) === false) $emailErrors[] = 'From email must be valid.';
if ($recipients !== '') foreach (preg_split('/[\\s,;]+/', $recipients, -1, PREG_SPLIT_NO_EMPTY) as $recipient) if (filter_var($recipient, FILTER_VALIDATE_EMAIL) === false) $emailErrors[] = 'Every notification recipient must be a valid email address.';
$passwordCiphertext = $emailSettings['smtp_password_ciphertext'] ?? null; $smtpPassword = scalar_input($_POST['smtp_password'] ?? null);
if ($smtpPassword !== '') $passwordCiphertext = (new \App\Domain\Credential\CredentialVault())->encrypt($smtpPassword);
if (!$emailErrors) {
$save = db()->prepare('INSERT INTO email_settings (id, smtp_host, smtp_port, smtp_encryption, smtp_username, smtp_password_ciphertext, from_email, from_name, notification_recipients, assignment_enabled, status_enabled, sla_enabled, overdue_enabled, updated_by) VALUES (1, :host, :port, :encryption, :username, :password, :from_email, :from_name, :recipients, :assignment, :status, :sla, :overdue, :user) ON DUPLICATE KEY UPDATE smtp_host = VALUES(smtp_host), smtp_port = VALUES(smtp_port), smtp_encryption = VALUES(smtp_encryption), smtp_username = VALUES(smtp_username), smtp_password_ciphertext = VALUES(smtp_password_ciphertext), from_email = VALUES(from_email), from_name = VALUES(from_name), notification_recipients = VALUES(notification_recipients), assignment_enabled = VALUES(assignment_enabled), status_enabled = VALUES(status_enabled), sla_enabled = VALUES(sla_enabled), overdue_enabled = VALUES(overdue_enabled), updated_by = VALUES(updated_by)');
$save->execute(['host' => $smtpHost ?: null, 'port' => $smtpPort, 'encryption' => in_array($_POST['smtp_encryption'] ?? '', ['none','tls','ssl'], true) ? $_POST['smtp_encryption'] : 'tls', 'username' => trim(scalar_input($_POST['smtp_username'] ?? null)) ?: null, 'password' => $passwordCiphertext, 'from_email' => $fromEmail ?: null, 'from_name' => trim(scalar_input($_POST['from_name'] ?? null)) ?: null, 'recipients' => $recipients ?: null, 'assignment' => isset($_POST['assignment_enabled']) ? 1 : 0, 'status' => isset($_POST['status_enabled']) ? 1 : 0, 'sla' => isset($_POST['sla_enabled']) ? 1 : 0, 'overdue' => isset($_POST['overdue_enabled']) ? 1 : 0, 'user' => $user['id']]);
audit('email_settings_updated', 'email_settings', 1); header('Location: /?route=email_settings&updated=1'); exit;
}
$emailSettings = array_merge($emailSettings, $_POST);
}
render_header('Email settings');
echo '<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;
}
if ($route === 'settings') {
if (($user['role_name'] ?? '') !== 'Administrator') { http_response_code(403); exit('Forbidden'); }
$settingsErrors = [];
@@ -890,7 +951,8 @@ if ($route === 'reports') {
if ($route === 'audit') {
require_permission('audit.view');
$stmt = db()->query('SELECT a.id, a.action, a.entity_type, a.entity_id, a.metadata, a.ip_address, a.created_at, u.name AS user_name FROM audit_events a LEFT JOIN users u ON u.id = a.user_id ORDER BY a.created_at DESC, a.id DESC LIMIT 200');
$page = max(1, (int)filter_var(scalar_input($_GET['page'] ?? null), FILTER_VALIDATE_INT)); $offset = ($page - 1) * 50;
$stmt = db()->prepare('SELECT a.id, a.action, a.entity_type, a.entity_id, a.metadata, a.ip_address, a.created_at, u.name AS user_name FROM audit_events a LEFT JOIN users u ON u.id = a.user_id ORDER BY a.created_at DESC, a.id DESC LIMIT 50 OFFSET ' . $offset); $stmt->execute();
render_header('Audit trail'); echo '<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;
}