feat: add client sla balances report totals and branding settings

This commit is contained in:
Marco0300
2026-09-01 23:16:34 +02:00
parent 9627da20e1
commit 1a7111c78d
7 changed files with 86 additions and 5 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ function deployment_format_check_report(array $checks): array
function deployment_check_schema(PDO $pdo): bool 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) . '`'; $quoted = '`' . str_replace('`', '``', $table) . '`';
$pdo->query("SELECT 1 FROM {$quoted} LIMIT 1"); $pdo->query("SELECT 1 FROM {$quoted} LIMIT 1");
} }
+9
View File
@@ -46,6 +46,15 @@ function db(): PDO
return $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 function csrf_token(): string
{ {
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32)); if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
+8
View File
@@ -32,6 +32,14 @@ CREATE TABLE IF NOT EXISTS users (
FOREIGN KEY (role_id) REFERENCES roles(id) FOREIGN KEY (role_id) REFERENCES roles(id)
) ENGINE=InnoDB; ) 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 ( CREATE TABLE IF NOT EXISTS clients (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL, name VARCHAR(190) NOT NULL,
+8
View File
@@ -1,4 +1,12 @@
-- JOBcard additive upgrade for installations created before the current schema. -- 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: -- Take a database backup first. Run with the target database selected:
-- mysql --default-character-set=utf8mb4 -u USER -p DATABASE < database/upgrade.sql -- mysql --default-character-set=utf8mb4 -u USER -p DATABASE < database/upgrade.sql
-- Resolve duplicate SLA rows before adding the unique client constraint. -- Resolve duplicate SLA rows before adding the unique client constraint.
+1
View File
@@ -8,6 +8,7 @@
--jc-border: #e8eaf2; --jc-border: #e8eaf2;
--jc-shadow: 0 14px 38px rgba(20, 28, 55, .08); --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; } * { 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; } 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); } a { color: var(--jc-primary); }
+54 -3
View File
@@ -42,14 +42,18 @@ session_start();
function render_header(string $title): void function render_header(string $title): void
{ {
$user = current_user(); $user = current_user();
echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . e($title) . ' · JOBcard</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>'; $brandName = app_setting('company_name', 'JOBcard') ?: 'JOBcard';
$logoFile = app_setting('logo_filename');
$brandMark = $logoFile ? '<img src="/assets/branding/' . e(basename($logoFile)) . '" alt="" class="brand-logo">' : '';
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) { if ($user) {
echo '<nav class="navbar navbar-dark bg-primary"><div class="container-fluid"><a class="navbar-brand fw-bold" href="/?route=dashboard">JOBcard</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">' . $brandMark . e($brandName) . '</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>';
if (can('clients.view')) echo '<a class="nav-link sidebar-link" href="/?route=clients">Clients</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('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>'; if (can('reports.view')) echo '<a class="nav-link sidebar-link" href="/?route=reports">Reports</a>';
if (can('users.manage')) echo '<a class="nav-link sidebar-link" href="/?route=users">Users & roles</a>'; if (can('users.manage')) echo '<a class="nav-link sidebar-link" href="/?route=users">Users & roles</a>';
if (can('roles.manage')) echo '<a class="nav-link sidebar-link" href="/?route=roles">Roles & permissions</a>'; if (can('roles.manage')) echo '<a class="nav-link sidebar-link" href="/?route=roles">Roles & permissions</a>';
if ($user['role_name'] === 'Administrator') echo '<a class="nav-link sidebar-link" href="/?route=settings">Settings</a>';
if (can('notifications.view')) echo '<a class="nav-link sidebar-link" href="/?route=notifications">Notifications</a>'; if (can('notifications.view')) echo '<a class="nav-link sidebar-link" href="/?route=notifications">Notifications</a>';
if (can('audit.view')) echo '<a class="nav-link sidebar-link" href="/?route=audit">Audit trail</a>'; if (can('audit.view')) echo '<a class="nav-link sidebar-link" href="/?route=audit">Audit trail</a>';
echo '</nav></aside><main class="col-md-10 col-lg-10 p-3 p-lg-4">'; echo '</nav></aside><main class="col-md-10 col-lg-10 p-3 p-lg-4">';
@@ -547,6 +551,12 @@ if ($route === 'client') {
$slaStmt = db()->prepare('SELECT * FROM sla_agreements WHERE client_id = :client LIMIT 1'); $slaStmt = db()->prepare('SELECT * FROM sla_agreements WHERE client_id = :client LIMIT 1');
$slaStmt->execute(['client' => $clientId]); $slaStmt->execute(['client' => $clientId]);
$slaAgreement = $slaStmt->fetch() ?: null; $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 = []; $credentialRows = [];
if (can('credentials.view') || can('credentials.manage')) { if (can('credentials.view') || can('credentials.manage')) {
@@ -563,8 +573,9 @@ if ($route === 'client') {
echo '</div></div></div></div>'; echo '</div></div></div></div>';
if (can('sla.view') || can('sla.manage')) { if (can('sla.view') || can('sla.manage')) {
echo '<div class="card mt-4" data-client-section="sla"><div class="card-body"><h2 class="h5">SLA agreement</h2>'; echo '<div class="card mt-4" data-client-section="sla"><div class="card-body"><h2 class="h5">SLA agreement</h2>';
if ($slaAgreement && (int)$slaAgreement['enabled'] === 1) echo '<div class="alert alert-primary"><strong>' . e(number_format((float)$slaAgreement['remaining_hours'], 2)) . ' SLA hours remaining</strong><div class="small">' . e(number_format((float)$slaAgreement['used_hours'], 2)) . ' of ' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours used in the current period.</div></div>';
if (can('sla.manage')) { echo '<form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="sla"><div class="col-md-3 form-check ms-2"><input class="form-check-input" type="checkbox" name="enabled" value="1" id="sla-enabled"' . (($slaAgreement['enabled'] ?? true) ? ' checked' : '') . '><label class="form-check-label" for="sla-enabled">Enabled</label></div><div class="col-md-4"><label class="form-label">Agreement type</label><input class="form-control" name="agreement_type" value="' . e((string)($slaAgreement['agreement_type'] ?? '')) . '"></div><div class="col-md-3"><label class="form-label">Allocated hours</label><input class="form-control" type="number" min="0" step="0.01" name="allocated_hours" value="' . e((string)($slaAgreement['allocated_hours'] ?? '0')) . '"></div><div class="col-md-3"><label class="form-label">Period</label><select class="form-select" name="period_type">'; foreach (['monthly','annual','custom'] as $period) echo '<option value="' . $period . '"' . (($slaAgreement['period_type'] ?? 'monthly') === $period ? ' selected' : '') . '>' . e(ucfirst($period)) . '</option>'; echo '</select></div><div class="col-md-3"><label class="form-label">Start date</label><input class="form-control" type="date" name="start_date" value="' . e((string)($slaAgreement['start_date'] ?? '')) . '"></div><div class="col-md-3"><label class="form-label">End date</label><input class="form-control" type="date" name="end_date" value="' . e((string)($slaAgreement['end_date'] ?? '')) . '"></div><div class="col-md-3 form-check pt-4"><input class="form-check-input" type="checkbox" name="rollover_enabled" value="1" id="sla-rollover"' . (($slaAgreement['rollover_enabled'] ?? false) ? ' checked' : '') . '><label class="form-check-label" for="sla-rollover">Rollover enabled</label></div><div class="col-12"><label class="form-label">SLA notes</label><textarea class="form-control" name="notes" rows="3">' . e((string)($slaAgreement['notes'] ?? '')) . '</textarea></div><div class="col-12"><button class="btn btn-primary">Save SLA</button></div></form>'; if (can('sla.manage')) { echo '<form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="sla"><div class="col-md-3 form-check ms-2"><input class="form-check-input" type="checkbox" name="enabled" value="1" id="sla-enabled"' . (($slaAgreement['enabled'] ?? true) ? ' checked' : '') . '><label class="form-check-label" for="sla-enabled">Enabled</label></div><div class="col-md-4"><label class="form-label">Agreement type</label><input class="form-control" name="agreement_type" value="' . e((string)($slaAgreement['agreement_type'] ?? '')) . '"></div><div class="col-md-3"><label class="form-label">Allocated hours</label><input class="form-control" type="number" min="0" step="0.01" name="allocated_hours" value="' . e((string)($slaAgreement['allocated_hours'] ?? '0')) . '"></div><div class="col-md-3"><label class="form-label">Period</label><select class="form-select" name="period_type">'; foreach (['monthly','annual','custom'] as $period) echo '<option value="' . $period . '"' . (($slaAgreement['period_type'] ?? 'monthly') === $period ? ' selected' : '') . '>' . e(ucfirst($period)) . '</option>'; echo '</select></div><div class="col-md-3"><label class="form-label">Start date</label><input class="form-control" type="date" name="start_date" value="' . e((string)($slaAgreement['start_date'] ?? '')) . '"></div><div class="col-md-3"><label class="form-label">End date</label><input class="form-control" type="date" name="end_date" value="' . e((string)($slaAgreement['end_date'] ?? '')) . '"></div><div class="col-md-3 form-check pt-4"><input class="form-check-input" type="checkbox" name="rollover_enabled" value="1" id="sla-rollover"' . (($slaAgreement['rollover_enabled'] ?? false) ? ' checked' : '') . '><label class="form-check-label" for="sla-rollover">Rollover enabled</label></div><div class="col-12"><label class="form-label">SLA notes</label><textarea class="form-control" name="notes" rows="3">' . e((string)($slaAgreement['notes'] ?? '')) . '</textarea></div><div class="col-12"><button class="btn btn-primary">Save SLA</button></div></form>';
} elseif ($slaAgreement) echo '<dl class="row mb-0"><dt class="col-sm-3">Type</dt><dd class="col-sm-9">' . e((string)($slaAgreement['agreement_type'] ?? '—')) . '</dd><dt class="col-sm-3">Allocation</dt><dd class="col-sm-9">' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours / ' . e($slaAgreement['period_type']) . '</dd></dl>'; } elseif ($slaAgreement) echo '<dl class="row mb-0"><dt class="col-sm-3">Type</dt><dd class="col-sm-9">' . e((string)($slaAgreement['agreement_type'] ?? '—')) . '</dd><dt class="col-sm-3">Allocation</dt><dd class="col-sm-9">' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours / ' . e($slaAgreement['period_type']) . '</dd><dt class="col-sm-3">Used</dt><dd class="col-sm-9">' . e(number_format((float)($slaAgreement['used_hours'] ?? 0), 2)) . ' hours</dd><dt class="col-sm-3">Remaining</dt><dd class="col-sm-9"><strong>' . e(number_format((float)($slaAgreement['remaining_hours'] ?? $slaAgreement['allocated_hours']), 2)) . ' hours</strong></dd></dl>';
else echo '<p class="text-muted mb-0">No SLA agreement configured.</p>'; else echo '<p class="text-muted mb-0">No SLA agreement configured.</p>';
echo '</div></div>'; echo '</div></div>';
} }
@@ -694,6 +705,44 @@ if ($route === 'roles') {
render_footer(); exit; 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 '<div class="mb-4"><h1 class="h3 mb-1">White-label settings</h1><p class="text-muted mb-0">Customize the company name and logo shown across the workspace.</p></div>' . (isset($_GET['updated']) ? '<div class="alert alert-success">Branding settings saved.</div>' : '') . ($settingsErrors ? '<div class="alert alert-danger">' . e(implode(' ', $settingsErrors)) . '</div>' : '') . '<div class="card"><div class="card-body"><form method="post" enctype="multipart/form-data" class="row g-4"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-7"><label class="form-label">Company name</label><input class="form-control" name="company_name" value="' . e($companyName) . '" maxlength="190" required></div><div class="col-md-5"><label class="form-label">Logo</label><input class="form-control" type="file" name="logo" accept="image/png,image/jpeg,image/gif,image/webp"><div class="form-text">PNG, JPG, GIF or WEBP up to 5 MB.</div>' . ($logoFile ? '<img class="brand-preview mt-3" src="/assets/branding/' . e(basename($logoFile)) . '" alt="Current logo">' : '') . '</div><div class="col-12"><button class="btn btn-primary">Save branding</button></div></form></div></div>';
render_footer(); exit;
}
if ($route === 'notifications') { if ($route === 'notifications') {
require_permission('notifications.view'); 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'); } } 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(); $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']; $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); $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 '<html><head><meta charset="UTF-8"></head><body><table border="1"><thead><tr>'; foreach ($detailHeaders as $header) echo '<th>' . e($header) . '</th>'; echo '</tr></thead><tbody>'; foreach ($detailData as $row) { echo '<tr>'; foreach ($row as $cell) echo '<td>' . e((string)($cell ?? '')) . '</td>'; echo '</tr>'; } echo '</tbody></table></body></html>'; exit; } 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 '<html><head><meta charset="UTF-8"></head><body><table border="1"><thead><tr>'; foreach ($detailHeaders as $header) echo '<th>' . e($header) . '</th>'; echo '</tr></thead><tbody>'; foreach ($detailData as $row) { echo '<tr>'; foreach ($row as $cell) echo '<td>' . e((string)($cell ?? '')) . '</td>'; echo '</tr>'; } echo '</tbody></table></body></html>'; 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; } 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; }
} }
+5 -1
View File
@@ -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, "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.'); 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}."); 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.');