feat: add client sla balances report totals and branding settings
This commit is contained in:
@@ -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); }
|
||||
|
||||
+54
-3
@@ -42,14 +42,18 @@ session_start();
|
||||
function render_header(string $title): void
|
||||
{
|
||||
$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) {
|
||||
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('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('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 ($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('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">';
|
||||
@@ -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 '</div></div></div></div>';
|
||||
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>';
|
||||
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>';
|
||||
} 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>';
|
||||
echo '</div></div>';
|
||||
}
|
||||
@@ -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 '<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') {
|
||||
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 '<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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user