diff --git a/bin/healthcheck.php b/bin/healthcheck.php
index 0d6a948..b5be8e5 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', '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', '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/config/bootstrap.php b/config/bootstrap.php
index 75f4d17..c3f52ab 100644
--- a/config/bootstrap.php
+++ b/config/bootstrap.php
@@ -46,6 +46,15 @@ function db(): PDO
return $pdo;
}
+function app_setting(string $key, ?string $default = null): ?string
+{
+ static $cache = [];
+ if (array_key_exists($key, $cache)) return $cache[$key];
+ try { $stmt = db()->prepare('SELECT setting_value FROM app_settings WHERE setting_key = :key'); $stmt->execute(['key' => $key]); $value = $stmt->fetchColumn(); }
+ catch (Throwable) { $value = false; }
+ return $cache[$key] = $value === false || $value === null ? $default : (string)$value;
+}
+
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
diff --git a/database/schema.sql b/database/schema.sql
index 933bdb2..9b73b20 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -32,6 +32,14 @@ CREATE TABLE IF NOT EXISTS users (
FOREIGN KEY (role_id) REFERENCES roles(id)
) ENGINE=InnoDB;
+CREATE TABLE IF NOT EXISTS app_settings (
+ setting_key VARCHAR(80) PRIMARY KEY,
+ setting_value TEXT NULL,
+ 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 clients (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL,
diff --git a/database/upgrade.sql b/database/upgrade.sql
index a287baa..de267c5 100644
--- a/database/upgrade.sql
+++ b/database/upgrade.sql
@@ -1,4 +1,12 @@
-- JOBcard additive upgrade for installations created before the current schema.
+CREATE TABLE IF NOT EXISTS app_settings (
+ setting_key VARCHAR(80) PRIMARY KEY,
+ setting_value TEXT NULL,
+ 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;
+
-- Take a database backup first. Run with the target database selected:
-- 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 c08e0ea..e8bf7f5 100644
--- a/public/assets/app.css
+++ b/public/assets/app.css
@@ -8,6 +8,7 @@
--jc-border: #e8eaf2;
--jc-shadow: 0 14px 38px rgba(20, 28, 55, .08);
}
+.brand-logo{width:32px;height:32px;object-fit:contain;border-radius:6px;background:#fff;padding:3px}.brand-preview{max-width:220px;max-height:90px;object-fit:contain;border:1px solid var(--jc-border);border-radius:10px;padding:8px;background:#fff}
* { 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); }
diff --git a/public/index.php b/public/index.php
index 81a19ab..b528210 100644
--- a/public/index.php
+++ b/public/index.php
@@ -42,14 +42,18 @@ session_start();
function render_header(string $title): void
{
$user = current_user();
- echo '
' . e($title) . ' · JOBcard ';
+ $brandName = app_setting('company_name', 'JOBcard') ?: 'JOBcard';
+ $logoFile = app_setting('logo_filename');
+ $brandMark = $logoFile ? ' ' : '';
+ echo '' . e($title) . ' · ' . e($brandName) . ' ';
if ($user) {
- echo 'JOBcard ' . e($user['name']) . ' · ' . e($user['role_name']) . ' ';
+ echo '';
if (can('clients.view')) echo '';
if (can('jobcards.view')) echo '';
if (can('reports.view')) echo '';
if (can('users.manage')) echo '';
if (can('roles.manage')) echo '';
+ if ($user['role_name'] === 'Administrator') echo '';
if (can('notifications.view')) echo '';
if (can('audit.view')) echo '';
echo ' ';
@@ -547,6 +551,12 @@ if ($route === 'client') {
$slaStmt = db()->prepare('SELECT * FROM sla_agreements WHERE client_id = :client LIMIT 1');
$slaStmt->execute(['client' => $clientId]);
$slaAgreement = $slaStmt->fetch() ?: null;
+ if ($slaAgreement && (int)$slaAgreement['enabled'] === 1) {
+ $slaUsageStmt = db()->prepare("SELECT COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 THEN te.hours ELSE 0 END), 0) FROM time_entries te JOIN jobcards j ON j.id = te.jobcard_id WHERE j.client_id = :client AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided') AND ((:period_monthly = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (:period_annual = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR (:period_custom = 'custom' AND te.work_date BETWEEN COALESCE(:start_date, '1000-01-01') AND COALESCE(:end_date, CURDATE())))");
+ $slaUsageStmt->execute(['client' => $clientId, 'period_monthly' => $slaAgreement['period_type'], 'period_annual' => $slaAgreement['period_type'], 'period_custom' => $slaAgreement['period_type'], 'start_date' => $slaAgreement['start_date'], 'end_date' => $slaAgreement['end_date']]);
+ $slaAgreement['used_hours'] = (float)$slaUsageStmt->fetchColumn();
+ $slaAgreement['remaining_hours'] = max(0.0, (float)$slaAgreement['allocated_hours'] - $slaAgreement['used_hours']);
+ }
}
$credentialRows = [];
if (can('credentials.view') || can('credentials.manage')) {
@@ -563,8 +573,9 @@ if ($route === 'client') {
echo ' ';
if (can('sla.view') || can('sla.manage')) {
echo 'SLA agreement ';
+ if ($slaAgreement && (int)$slaAgreement['enabled'] === 1) echo '
' . e(number_format((float)$slaAgreement['remaining_hours'], 2)) . ' SLA hours remaining ' . e(number_format((float)$slaAgreement['used_hours'], 2)) . ' of ' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours used in the current period.
';
if (can('sla.manage')) { echo '
';
- } elseif ($slaAgreement) echo '
Type ' . e((string)($slaAgreement['agreement_type'] ?? '—')) . ' Allocation ' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours / ' . e($slaAgreement['period_type']) . ' ';
+ } elseif ($slaAgreement) echo '
Type ' . e((string)($slaAgreement['agreement_type'] ?? '—')) . ' Allocation ' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours / ' . e($slaAgreement['period_type']) . ' Used ' . e(number_format((float)($slaAgreement['used_hours'] ?? 0), 2)) . ' hours Remaining ' . e(number_format((float)($slaAgreement['remaining_hours'] ?? $slaAgreement['allocated_hours']), 2)) . ' hours ';
else echo '
No SLA agreement configured.
';
echo '
';
}
@@ -694,6 +705,44 @@ if ($route === 'roles') {
render_footer(); exit;
}
+if ($route === 'settings') {
+ if (($user['role_name'] ?? '') !== 'Administrator') { http_response_code(403); exit('Forbidden'); }
+ $settingsErrors = [];
+ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
+ verify_csrf();
+ $companyName = trim(scalar_input($_POST['company_name'] ?? null));
+ if ($companyName === '' || mb_strlen($companyName) > 190) $settingsErrors[] = 'Company name is required and must be 190 characters or fewer.';
+ $logo = $_FILES['logo'] ?? null;
+ $newLogo = null;
+ if (is_array($logo) && ($logo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
+ if (($logo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($logo['tmp_name'] ?? '')) $settingsErrors[] = 'Logo upload failed.';
+ else {
+ $mime = (new finfo(FILEINFO_MIME_TYPE))->file($logo['tmp_name']);
+ $extensions = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp'];
+ if (!isset($extensions[$mime]) || (int)($logo['size'] ?? 0) > 5 * 1024 * 1024) $settingsErrors[] = 'Logo must be a PNG, JPG, GIF or WEBP image up to 5 MB.';
+ else $newLogo = bin2hex(random_bytes(16)) . '.' . $extensions[$mime];
+ }
+ }
+ if (!$settingsErrors) {
+ $dir = __DIR__ . '/assets/branding';
+ if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) $settingsErrors[] = 'Branding storage is unavailable.';
+ if (!$settingsErrors && $newLogo !== null && !move_uploaded_file($logo['tmp_name'], $dir . '/' . $newLogo)) $settingsErrors[] = 'Logo could not be stored.';
+ if (!$settingsErrors) {
+ $pdo = db();
+ $save = $pdo->prepare('INSERT INTO app_settings (setting_key, setting_value, updated_by) VALUES (:key, :value, :user) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_by = VALUES(updated_by)');
+ $save->execute(['key' => 'company_name', 'value' => $companyName, 'user' => $user['id']]);
+ if ($newLogo !== null) $save->execute(['key' => 'logo_filename', 'value' => $newLogo, 'user' => $user['id']]);
+ audit('branding_settings_updated', 'app_settings', 0, ['logo_updated' => $newLogo !== null]);
+ header('Location: /?route=settings&updated=1'); exit;
+ }
+ }
+ }
+ $companyName = app_setting('company_name', 'JOBcard') ?: 'JOBcard';
+ $logoFile = app_setting('logo_filename');
+ render_header('Settings');
+ echo 'White-label settings Customize the company name and logo shown across the workspace.
' . (isset($_GET['updated']) ? 'Branding settings saved.
' : '') . ($settingsErrors ? '' . e(implode(' ', $settingsErrors)) . '
' : '') . '';
+ render_footer(); exit;
+}
if ($route === 'notifications') {
require_permission('notifications.view');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $notificationId = filter_var(scalar_input($_POST['notification_id'] ?? null), FILTER_VALIDATE_INT); try { if (!(new \App\Domain\Notification\NotificationQueue())->markRead(db(), (int)$user['id'], ['notification_id' => $notificationId])) { http_response_code(404); exit('Notification not found'); } audit('notification_read', 'notification', (int)$notificationId); header('Location: /?route=notifications&read=1'); exit; } catch (Throwable $exception) { http_response_code(400); exit('Invalid notification'); } }
@@ -720,6 +769,8 @@ if ($route === 'reports') {
$detailStmt = db()->prepare($detailSql); $detailStmt->execute($detailParams); $detailRows = $detailStmt->fetchAll();
$detailHeaders = ['Jobcard', 'Client', 'Created', 'Status', 'Work requested', 'Technician notes / Work performed', 'Technician', 'Work date', 'Hours', 'Hours notes'];
$detailData = array_map(static fn(array $row): array => [$row['reference_no'], $row['client_name'], $row['created_at'], $row['status'], $row['work_requested'], $row['technician_notes'], $row['technician_name'], $row['work_date'], $row['hours'], $row['hours_notes']], $detailRows);
+ $totalReportHours = array_sum(array_map(static fn(array $row): float => (float)($row['hours'] ?? 0), $detailRows));
+ $detailData[] = ['TOTAL HOURS', '', '', '', '', '', '', '', round($totalReportHours, 2), ''];
if ($format === 'xls') { header('Content-Type: application/vnd.ms-excel; charset=UTF-8'); header('Content-Disposition: attachment; filename="jobcard-detail-report.xls"'); header('Cache-Control: no-store'); echo ''; foreach ($detailHeaders as $header) echo '' . e($header) . ' '; echo ' '; foreach ($detailData as $row) { echo ''; foreach ($row as $cell) echo '' . e((string)($cell ?? '')) . ' '; echo ' '; } echo '
'; exit; }
if ($format === 'print') { header('Content-Type: text/html; charset=UTF-8'); header('Cache-Control: no-store'); echo (new \PrintReportRenderer())->render('Detailed jobcard report', $detailHeaders, $detailData); exit; }
}
diff --git a/tests/JobcardSearchCloseReportTest.php b/tests/JobcardSearchCloseReportTest.php
index d1247af..bde1687 100644
--- a/tests/JobcardSearchCloseReportTest.php
+++ b/tests/JobcardSearchCloseReportTest.php
@@ -18,4 +18,8 @@ jobcard_feature_assert(str_contains($front, 'format=xls&detail=1'), 'Reports mus
jobcard_feature_assert(str_contains($front, "if (\$user['role_name'] === 'Technician') {") && str_contains($front, '$reportClientsStmt->execute'), 'Report client dropdown must bind parameters only on the technician prepared-query branch.');
jobcard_feature_assert(str_contains($front, '$reportClientsStmt->execute([(int)$user[\'id\']])'), 'Technician report client lookup must use a positional bound parameter.');
foreach (['Work requested', 'Technician notes / Work performed', 'Hours notes'] as $heading) jobcard_feature_assert(str_contains($front, $heading), "Detailed report must include {$heading}.");
-printf("Jobcard search/close/report tests: 10 passed\n");
+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, '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