diff --git a/bin/healthcheck.php b/bin/healthcheck.php index b5be8e5..97db3c3 100644 --- a/bin/healthcheck.php +++ b/bin/healthcheck.php @@ -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"); } diff --git a/database/schema.sql b/database/schema.sql index 7a1db94..8b1e747 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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, diff --git a/database/upgrade.sql b/database/upgrade.sql index 48a919d..2b8cb2c 100644 --- a/database/upgrade.sql +++ b/database/upgrade.sql @@ -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. diff --git a/public/assets/app.css b/public/assets/app.css index be410b0..9bf4902 100644 --- a/public/assets/app.css +++ b/public/assets/app.css @@ -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; } } diff --git a/public/index.php b/public/index.php index dee38a2..9bdd81c 100644 --- a/public/index.php +++ b/public/index.php @@ -49,7 +49,7 @@ function render_header(string $title): void $brandLabel = $logoFile ? $brandMark : e($brandName); echo '' . e($title) . ' · ' . e($brandName) . ''; if ($user) { - echo '
' : '') . $mobileNav . ''; } $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 '

Search

Results for “' . e($query) . '”

'; if (!$results) echo '
No matching records found.
'; foreach ($results as $result) echo '
' . e($result['result_title']) . '' . e($result['result_type']) . '
' . e((string)$result['result_context']) . '
'; echo '
'; 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 ''; if (isset($_GET['created'])) echo '
Jobcard created successfully.
'; if ($errors) echo '
' . e(implode(' ', $errors)) . '
'; - echo '
'; + echo '
My open jobsAssignedIn progressClosed
'; foreach ($savedFilters as $saved) { $savedData = json_decode((string)$saved['filter_json'], true); if (is_array($savedData)) echo '' . e($saved['name']) . ''; } echo '
'; if (can('jobcards.manage')) { echo '

Create jobcard

'; } echo '
'; if (!$jobcards) echo ''; foreach ($jobcards as $jobcard) echo ''; - echo '
ReferenceClientPriorityStatusWork requestedCreated
No jobcards found.
' . e($jobcard['reference_no']) . '' . e($jobcard['client_name']) . '' . e(ucfirst($jobcard['priority'])) . '' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '' . e($jobcard['work_requested']) . '' . e($jobcard['created_at']) . '
'; + echo '
PreviousNext
'; 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 '
'; if (!$clients) echo ''; foreach ($clients as $client) echo ''; - echo '
ClientStatusSupport emailPhone
No clients found.
' . e($client['name']) . '' . e(ucfirst($client['status'])) . '' . e((string)($client['support_email'] ?? '—')) . '' . e((string)($client['support_phone'] ?? '—')) . '
'; + echo '
PreviousNext
'; 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 '

Edit user

Cancel
Leave blank to keep the current password.
'; echo '

Users

Create and review system accounts.

' . (isset($_GET['created']) ? '
User created successfully.
' : '') . ($userErrors ? '
' . e(implode(' ', $userErrors)) . '
' : '') . '
Use at least 12 characters with upper/lowercase, number and symbol.
'; foreach ($users as $listedUser) echo ''; - echo '
NameEmailRoleStatusLast loginActions
' . e($listedUser['name']) . '' . e($listedUser['email']) . '' . e($listedUser['role_name']) . '' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . '' . e((string)($listedUser['last_login_at'] ?? 'Never')) . 'Edit ' . (($user['role_name'] === 'Administrator' && (int)$listedUser['id'] !== (int)$user['id'] && $listedUser['role_name'] !== 'Administrator') ? '
' : '') . '
'; render_footer(); exit; + echo '
PreviousNext
'; 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 '
← Back to settings

Email notification settings

Configure SMTP delivery and which events generate email notifications.

' . (isset($_GET['updated']) ? '
Email settings saved.
' : '') . ($emailErrors ? '
' . e(implode(' ', $emailErrors)) . '
' : '') . '

Notification events

'; 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 '

Audit trail

'; foreach ($stmt->fetchAll() as $event) echo ''; echo '
WhenUserActionEntityMetadata
' . e($event['created_at']) . '' . e((string)($event['user_name'] ?? 'System')) . '' . e($event['action']) . '' . e($event['entity_type']) . ' #' . (int)$event['entity_id'] . '' . e((string)($event['metadata'] ?? '')) . '
'; render_footer(); exit; } diff --git a/tests/JobcardSearchCloseReportTest.php b/tests/JobcardSearchCloseReportTest.php index c9f46ce..08ea133 100644 --- a/tests/JobcardSearchCloseReportTest.php +++ b/tests/JobcardSearchCloseReportTest.php @@ -27,4 +27,8 @@ jobcard_feature_assert(str_contains($front, 'remaining_hours') && str_contains($ 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.'); \ No newline at end of file +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, "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.');