From 1a7111c78d308f71c6ec3d32f307a16fa919959f Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Tue, 1 Sep 2026 23:16:34 +0200 Subject: [PATCH] feat: add client sla balances report totals and branding settings --- bin/healthcheck.php | 2 +- config/bootstrap.php | 9 ++++ database/schema.sql | 8 ++++ database/upgrade.sql | 8 ++++ public/assets/app.css | 1 + public/index.php | 57 ++++++++++++++++++++++++-- tests/JobcardSearchCloseReportTest.php | 6 ++- 7 files changed, 86 insertions(+), 5 deletions(-) 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 '
'; 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)) . '
' : '') . '
PNG, JPG, GIF or WEBP up to 5 MB.
' . ($logoFile ? 'Current logo' : '') . '
'; + 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 ''; echo ''; foreach ($detailData as $row) { echo ''; foreach ($row as $cell) echo ''; echo ''; } echo '
' . e($header) . '
' . e((string)($cell ?? '')) . '
'; 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