Compare commits

..
10 Commits
7 changed files with 178 additions and 25 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ function deployment_format_check_report(array $checks): array
function deployment_check_schema(PDO $pdo): bool
{
foreach (['roles', 'permissions', 'role_permissions', 'users', 'app_settings', 'clients', 'client_contacts', 'jobcard_sequences', 'technical_information', 'credentials', 'sla_agreements', 'jobcards', 'jobcard_assignments', 'jobcard_status_history', 'time_entries', 'attachments', 'notifications', 'audit_events'] as $table) {
foreach (['roles', 'permissions', 'role_permissions', 'users', 'email_settings', 'saved_filters', 'app_settings', 'clients', 'client_contacts', 'jobcard_sequences', 'technical_information', 'credentials', 'sla_agreements', 'jobcards', 'jobcard_assignments', 'jobcard_status_history', 'time_entries', 'attachments', 'notifications', 'audit_events'] as $table) {
$quoted = '`' . str_replace('`', '``', $table) . '`';
$pdo->query("SELECT 1 FROM {$quoted} LIMIT 1");
}
+31
View File
@@ -32,6 +32,37 @@ CREATE TABLE IF NOT EXISTS users (
FOREIGN KEY (role_id) REFERENCES roles(id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS email_settings (
id TINYINT UNSIGNED PRIMARY KEY,
smtp_host VARCHAR(190) NULL,
smtp_port SMALLINT UNSIGNED NULL,
smtp_encryption ENUM('none','tls','ssl') NOT NULL DEFAULT 'tls',
smtp_username VARCHAR(190) NULL,
smtp_password_ciphertext TEXT NULL,
from_email VARCHAR(190) NULL,
from_name VARCHAR(190) NULL,
notification_recipients TEXT NULL,
assignment_enabled BOOLEAN NOT NULL DEFAULT TRUE,
status_enabled BOOLEAN NOT NULL DEFAULT TRUE,
sla_enabled BOOLEAN NOT NULL DEFAULT TRUE,
overdue_enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_by BIGINT UNSIGNED NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS saved_filters (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
route VARCHAR(60) NOT NULL,
filter_json JSON NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY saved_filter_user_name (user_id, name),
INDEX saved_filter_route_idx (user_id, route)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS app_settings (
setting_key VARCHAR(80) PRIMARY KEY,
setting_value TEXT NULL,
+32 -1
View File
@@ -7,7 +7,38 @@ CREATE TABLE IF NOT EXISTS app_settings (
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- Take a database backup first. Run with the target database selected:
CREATE TABLE IF NOT EXISTS email_settings (
id TINYINT UNSIGNED PRIMARY KEY,
smtp_host VARCHAR(190) NULL,
smtp_port SMALLINT UNSIGNED NULL,
smtp_encryption ENUM('none','tls','ssl') NOT NULL DEFAULT 'tls',
smtp_username VARCHAR(190) NULL,
smtp_password_ciphertext TEXT NULL,
from_email VARCHAR(190) NULL,
from_name VARCHAR(190) NULL,
notification_recipients TEXT NULL,
assignment_enabled BOOLEAN NOT NULL DEFAULT TRUE,
status_enabled BOOLEAN NOT NULL DEFAULT TRUE,
sla_enabled BOOLEAN NOT NULL DEFAULT TRUE,
overdue_enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_by BIGINT UNSIGNED NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS saved_filters (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
route VARCHAR(60) NOT NULL,
filter_json JSON NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY saved_filter_user_name (user_id, name),
INDEX saved_filter_route_idx (user_id, route)
) ENGINE=InnoDB;
-- mysql --default-character-set=utf8mb4 -u USER -p DATABASE < database/upgrade.sql
-- Resolve duplicate SLA rows before adding the unique client constraint.
+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; } }
+94 -19
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="collapse d-md-block 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[data-track-unsaved]").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'), 'technician_id' => filter_var(scalar_input($_POST['technician_id'] ?? null), FILTER_VALIDATE_INT) ?: null], JSON_THROW_ON_ERROR);
db()->prepare('INSERT INTO saved_filters (user_id, name, route, filter_json) VALUES (:user, :name, :route, :filters) ON DUPLICATE KEY UPDATE filter_json = VALUES(filter_json)')->execute(['user' => $user['id'], 'name' => $name, 'route' => 'jobcards', 'filters' => $filterJson]);
header('Location: /?route=jobcards&saved=1'); exit;
}
if ($route === 'search') {
require_permission('jobcards.view');
$query = trim(scalar_input($_GET['q'] ?? null));
if ($query === '') { header('Location: /?route=dashboard'); exit; }
$like = '%' . $query . '%'; $parts = []; $params = [];
$jobScope = $user['role_name'] === 'Technician' ? ' JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ?' : '';
if ($user['role_name'] === 'Technician') $params[] = $user['id'];
$parts[] = 'SELECT DISTINCT j.id, CONCAT("Jobcard ", j.reference_no) AS result_title, c.name AS result_context, CONCAT("/?route=jobcard&id=", j.id) AS result_url, "Jobcard" AS result_type FROM jobcards j JOIN clients c ON c.id = j.client_id' . $jobScope . ' WHERE (j.reference_no LIKE ? OR c.name LIKE ? OR j.work_requested LIKE ? OR j.technician_notes LIKE ?) AND j.status <> "closed"'; array_push($params, $like, $like, $like, $like);
$contactScope = $user['role_name'] === 'Technician' ? ' JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ?' : '';
if ($user['role_name'] === 'Technician') $params[] = $user['id'];
$parts[] = 'SELECT DISTINCT c.id, CONCAT("Client: ", c.name), c.support_email, CONCAT("/?route=client&id=", c.id), "Client" FROM clients c' . $contactScope . ' WHERE (c.name LIKE ? OR c.support_email LIKE ?)'; array_push($params, $like, $like);
if ($user['role_name'] !== 'Technician') { $parts[] = 'SELECT DISTINCT cc.id, CONCAT("Contact: ", cc.name), c.name, CONCAT("/?route=client&id=", c.id), "Contact" FROM client_contacts cc JOIN clients c ON c.id = cc.client_id WHERE (cc.name LIKE ? OR cc.email LIKE ? OR cc.phone LIKE ?)'; array_push($params, $like, $like, $like); }
$attachmentScope = $user['role_name'] === 'Technician' ? ' JOIN jobcard_assignments ja ON ja.jobcard_id = a.jobcard_id AND ja.user_id = ?' : '';
if ($user['role_name'] === 'Technician') $params[] = $user['id'];
$parts[] = 'SELECT DISTINCT a.id, CONCAT("Attachment: ", a.original_name), j.reference_no, CONCAT("/?route=attachment&id=", a.id), "Attachment" FROM attachments a JOIN jobcards j ON j.id = a.jobcard_id' . $attachmentScope . ' WHERE a.original_name LIKE ?'; $params[] = $like;
$searchStmt = db()->prepare(implode(' UNION ALL ', $parts) . ' ORDER BY result_type, result_title LIMIT 50'); $searchStmt->execute($params); $results = $searchStmt->fetchAll();
render_header('Search'); echo '<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();
@@ -189,7 +217,10 @@ if ($route === 'client_history') {
$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]);
$historyScope = '';
$historyParams = ['client' => $clientId];
if ($user['role_name'] === 'Technician') { $historyScope = ' JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :history_user'; $historyParams['history_user'] = $user['id']; }
$stmt = db()->prepare('SELECT DISTINCT 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' . $historyScope . ' 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($historyParams);
$history = (new \App\Domain\Reporting\ClientHistoryReport($filters))->build($stmt->fetchAll(), 'client');
if (scalar_input($_GET['format'] ?? null) === 'print') { header('Content-Type: text/html; charset=UTF-8'); echo (new \PrintReportRenderer())->render('Client history', ['Reference', 'From', 'To', 'Changed'], array_map(static fn (array $row): array => [$row['reference_no'], $row['from_status'], $row['to_status'], $row['changed_at']], $history)); exit; }
render_header('Client history'); echo '<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;
@@ -216,6 +247,8 @@ if ($route === 'jobcard') {
$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'); }
$clientSlaStmt = db()->prepare('SELECT 1 FROM sla_agreements WHERE client_id = :client AND enabled = 1 AND CURDATE() BETWEEN COALESCE(start_date, \'1000-01-01\') AND COALESCE(end_date, \'9999-12-31\') LIMIT 1');
$hasClientSla = (bool)$clientSlaStmt->execute(['client' => $jobcard['client_id']]) && (bool)$clientSlaStmt->fetchColumn();
$actionErrors = [];
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
@@ -285,7 +318,7 @@ if ($route === 'jobcard') {
$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'];
$timeInput = [...$_POST, 'jobcard_id' => $jobcardId, 'technician_id' => $technicianId, 'counts_toward_sla' => $hasClientSla && 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.';
@@ -352,7 +385,7 @@ if ($route === 'jobcard') {
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>';
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>';
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>'; }
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>' . ($hasClientSla ? '<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>'; }
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">';
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>';
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>';
@@ -409,15 +442,23 @@ 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';
$technicianFilter = filter_var(scalar_input($_GET['technician_id'] ?? ($_SESSION['jobcard_filters']['technician_id'] ?? null)), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($technicianFilter === false) $technicianFilter = null;
$_SESSION['jobcard_filters'] = ['q' => $search, 'status_filter' => $statusFilter, 'technician_id' => $technicianFilter];
$page = max(1, (int)filter_var(scalar_input($_GET['page'] ?? null), FILTER_VALIDATE_INT));
$offset = ($page - 1) * 25;
$savedFilterStmt = db()->prepare('SELECT id, name, filter_json FROM saved_filters WHERE user_id = :user AND route = :route ORDER BY name'); $savedFilterStmt->execute(['user' => $user['id'], 'route' => 'jobcards']); $savedFilters = $savedFilterStmt->fetchAll();
$conditions = [];
$params = [];
if ($search !== '') { $conditions[] = '(j.reference_no LIKE ? OR c.name LIKE ? OR j.work_requested LIKE ?)'; $like = '%' . $search . '%'; array_push($params, $like, $like, $like); }
if ($statusFilter === 'closed') { $conditions[] = "j.status = 'closed'"; } elseif ($statusFilter === 'in_progress') { $conditions[] = "j.status = 'in_progress'"; } elseif ($statusFilter === 'assigned') { $conditions[] = "j.status = 'assigned'"; } else { $conditions[] = "j.status <> 'closed'"; }
$scopeJoin = '';
if ($user['role_name'] === 'Technician') { $scopeJoin = ' JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ?'; array_unshift($params, (int)$user['id']); }
elseif ($technicianFilter !== null) { $scopeJoin = ' JOIN jobcard_assignments ja_filter ON ja_filter.jobcard_id = j.id AND ja_filter.user_id = ?'; array_unshift($params, $technicianFilter); }
$technicianOptions = $user['role_name'] === 'Technician' ? [] : 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();
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']]);
@@ -425,7 +466,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 +475,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><form class="row g-2 mb-2" method="get"><input type="hidden" name="route" value="jobcards"><input type="hidden" name="q" value="' . e($search) . '"><input type="hidden" name="status_filter" value="' . e($statusFilter) . '"><div class="col-md-6"><select class="form-select" name="technician_id"><option value="">All technicians</option>'; foreach ($technicianOptions as $technicianOption) echo '<option value="' . (int)$technicianOption['id'] . '"' . ((int)($technicianFilter ?? 0) === (int)$technicianOption['id'] ? ' selected' : '') . '>' . e($technicianOption['name']) . '</option>'; echo '</select></div><div class="col-md-3"><button class="btn btn-outline-secondary w-100">Filter technician</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 type="hidden" name="technician_id" value="' . e((string)($technicianFilter ?? '')) . '"><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) . '&technician_id=' . urlencode((string)($technicianFilter ?? '')) . '&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) . '&technician_id=' . urlencode((string)($technicianFilter ?? '')) . '&page=' . ($page + 1) . '">Next</a></div>';
render_footer(); exit;
}
@@ -647,10 +688,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 +711,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;
}
@@ -678,7 +721,7 @@ if ($route === 'user_edit') {
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();
$existingUsers = db()->query('SELECT u.id, u.name, u.email, u.role_id, u.is_active, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id')->fetchAll();
$target = null; foreach ($existingUsers as $candidate) if ((int)$candidate['id'] === (int)$targetId) { $target = $candidate; break; }
if (!$target) { http_response_code(404); exit('User not found'); }
$payload = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => isset($_POST['is_active']) ? '1' : '0'];
@@ -732,16 +775,22 @@ 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; }
if (isset($_SESSION['user_edit_errors'])) { $userErrors = (array)$_SESSION['user_edit_errors']; unset($_SESSION['user_edit_errors']); }
render_header('Users');
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Users & roles</h1><p class="text-muted mb-0">Manage user accounts and their assigned roles.</p></div></div><div class="card mb-4"><div class="table-responsive"><table class="table table-hover 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>';
if (!$users) echo '<tr><td colspan="6" class="text-center text-muted py-4">No users found.</td></tr>';
foreach ($users as $account) echo '<tr><td class="fw-semibold">' . e($account['name']) . '</td><td>' . e($account['email']) . '</td><td>' . e($account['role_name']) . '</td><td><span class="badge text-bg-' . ($account['is_active'] ? 'success' : 'secondary') . '">' . e($account['is_active'] ? 'Active' : 'Inactive') . '</span></td><td>' . e((string)($account['last_login_at'] ?? 'Never')) . '</td><td><a class="btn btn-sm btn-outline-primary" href="/?route=users&edit_user=' . (int)$account['id'] . '">Edit</a> ' . (($user['role_name'] === 'Administrator' && (int)$account['id'] !== (int)$user['id'] && $account['role_name'] !== 'Administrator') ? '<form method="post" action="/?route=user_delete" class="d-inline"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="user_id" value="' . (int)$account['id'] . '"><button class="btn btn-sm btn-outline-danger" data-confirm="Delete this user permanently?">Delete</button></form>' : '') . '</td></tr>';
echo '</tbody></table></div></div>';
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 +820,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 +964,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;
}
+9 -1
View File
@@ -24,7 +24,15 @@ jobcard_feature_assert(str_contains($front, '$reportClientsStmt->execute([(int)$
foreach (['Technician notes / Work performed', 'totalReportHours'] as $heading) jobcard_feature_assert(str_contains($front, $heading), "Detailed report must include {$heading}.");
jobcard_feature_assert(!str_contains($front, 'name=\"technician_notes\"') && !str_contains($front, 'Hours notes'), 'Jobcards must use time-entry technician notes only.');
jobcard_feature_assert(str_contains($front, 'remaining_hours') && str_contains($front, 'SLA hours remaining'), 'Client pages must show remaining SLA hours.');
jobcard_feature_assert(str_contains($front, '$hasClientSla ?') && str_contains($front, "'counts_toward_sla' => \$hasClientSla &&"), 'SLA checkbox and submitted flag must be conditional on an active client SLA.');
jobcard_feature_assert(str_contains($front, 'j.client_id = :client') && str_contains($front, 'te.counts_toward_sla = 1'), 'Client SLA usage must aggregate active-period SLA time entries regardless of jobcard status.');
jobcard_feature_assert(str_contains($front, "['TOTAL HOURS'") && str_contains($front, 'totalReportHours'), 'Detailed report downloads must include total hours.');
jobcard_feature_assert(str_contains($front, "if (\$route === 'settings')") && str_contains($front, "role_name'] ?? '') !== 'Administrator'"), 'Branding settings must be Administrator-only.');
jobcard_feature_assert(str_contains($front, 'app_settings') && str_contains($front, 'move_uploaded_file'), 'Branding settings must persist company name and safely upload logos.');
jobcard_feature_assert(str_contains($front, "if (\$route === 'search')") && str_contains($front, 'attachments') && str_contains($front, 'client_contacts'), 'Global search must cover jobcards, clients, contacts and attachments.');
jobcard_feature_assert(!str_contains($front, 'j.client_id = c.client_id'), 'Global client search must join jobcards through clients.id.');
jobcard_feature_assert(str_contains($front, "if (\$route === 'saved_filter_save')") && str_contains($front, 'saved_filters'), 'Users must be able to save Jobcards filters.');
jobcard_feature_assert(str_contains($front, 'LIMIT 25 OFFSET') && str_contains($front, 'LIMIT 50 OFFSET'), 'Large list pages must use bounded pagination.');
jobcard_feature_assert(str_contains($front, 'technician-bottom-nav') && str_contains($front, 'sidebarMenu'), 'Mobile navigation controls must be present.');
jobcard_feature_assert(str_contains($front, "if (\$route === 'email_settings')") && str_contains($front, 'smtp_password_ciphertext'), 'Administrator email settings must include encrypted SMTP credential storage.');
+2
View File
@@ -19,5 +19,7 @@ $password = $service->validatePasswordReset(['id' => 2], 'Strong-Password-42');
user_admin_assert($password['valid'] === true, 'A strong password reset should pass.');
$front = file_get_contents(dirname(__DIR__) . '/public/index.php');
user_admin_assert($front !== false && str_contains($front, "if (\$route === 'user_edit')") && str_contains($front, "if (\$route === 'user_delete')"), 'User edit and delete routes must exist.');
user_admin_assert(str_contains($front, 'SELECT u.id, u.name, u.email, u.role_id, u.is_active, r.name AS role_name FROM users u JOIN roles r'), 'User edit must qualify user columns when joining the roles table.');
user_admin_assert(str_contains($front, "role_name'] ?? '') !== 'Administrator'"), 'User deletion must be Administrator-only.');
printf("User administration tests: 5 passed\n");