feat: add close permissions jobcard filters and detailed xls reports

This commit is contained in:
Marco0300
2026-09-01 22:59:40 +02:00
parent d2ee80ebe7
commit 99aaaf3338
4 changed files with 59 additions and 10 deletions
+1
View File
@@ -238,6 +238,7 @@ INSERT IGNORE INTO permissions (name, description) VALUES
('clients.manage', 'Create and edit client records'),
('jobcards.view', 'View jobcards'),
('jobcards.manage', 'Create and update jobcards'),
('jobcards.close', 'Close jobcards'),
('jobcards.assign', 'Assign technicians to jobcards'),
('jobcards.internal_notes', 'View and edit internal jobcard notes'),
('time_entries.record', 'Record technician time entries'),
+1
View File
@@ -83,6 +83,7 @@ CREATE TABLE IF NOT EXISTS notifications (
INSERT IGNORE INTO permissions (name, description) VALUES
('technical.view', 'View client technical information'),
('technical.manage', 'Manage client technical information'),
('jobcards.close', 'Close jobcards'),
('credentials.view', 'View protected credentials'),
('credentials.manage', 'Manage protected credentials'),
('attachments.view', 'View jobcard attachments'),
+37 -9
View File
@@ -196,6 +196,7 @@ if ($route === 'jobcard') {
if ($action === 'status') {
require_permission('jobcards.manage');
$to = scalar_input($_POST['status'] ?? null);
if ($to === 'closed') require_permission('jobcards.close');
$pdo = db();
try {
$pdo->beginTransaction();
@@ -324,7 +325,7 @@ if ($route === 'jobcard') {
foreach ($timeEntries as $entry) echo '<div class="border-bottom py-2"><strong>' . e($entry['technician_name']) . '</strong> · ' . e($entry['work_date']) . ' · ' . e(number_format((float)$entry['hours'], 2)) . 'h<div class="small text-muted">' . e((string)($entry['notes'] ?? '')) . '</div>' . (can('time_entries.record') ? '<a class="small" href="/?route=time_entry&id=' . (int)$entry['id'] . '">Correct or void</a>' : '') . '</div>';
if (can('time_entries.record')) { echo '<hr><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="time">'; if ($user['role_name'] !== 'Technician') { echo '<div class="col-md-4"><select class="form-select" name="technician_id" required><option value="">Technician</option>'; foreach ($technicians as $technician) echo '<option value="' . (int)$technician['id'] . '">' . e($technician['name']) . '</option>'; echo '</select></div>'; } echo '<div class="col-md-4"><input class="form-control" type="date" name="work_date" value="' . e(date('Y-m-d')) . '" required></div><div class="col-md-4"><input class="form-control" type="number" step="0.01" min="0.01" name="hours" placeholder="Hours"></div><div class="col-md-4 form-check pt-2"><input class="form-check-input" type="checkbox" name="counts_toward_sla" value="1" id="sla-time" checked><label class="form-check-label" for="sla-time">Counts toward SLA</label></div><div class="col-12"><input class="form-control" name="notes" placeholder="Time entry notes"></div><div class="col-12"><button class="btn btn-outline-primary">Add time</button></div></form>'; }
echo '</div></div></div><div class="col-lg-4"><div class="card mb-4"><div class="card-body"><h2 class="h5">Status</h2><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="status"><select class="form-select mb-2" name="status">';
foreach ((new \App\Domain\Jobcard\StatusTransitionValidator())->allowedFrom($jobcard['status']) as $status) echo '<option value="' . e($status) . '"' . ($status === $jobcard['status'] ? ' selected' : '') . '>' . e(ucwords(str_replace('_', ' ', $status))) . '</option>';
foreach ((new \App\Domain\Jobcard\StatusTransitionValidator())->allowedFrom($jobcard['status']) as $status) if ($status !== 'closed' || can('jobcards.close')) echo '<option value="' . e($status) . '"' . ($status === $jobcard['status'] ? ' selected' : '') . '>' . e(ucwords(str_replace('_', ' ', $status))) . '</option>';
echo '</select><button class="btn btn-outline-primary w-100">Update status</button></form></div></div><div class="card"><div class="card-body"><h2 class="h5">Assigned technicians</h2>';
if (!$assigned) echo '<p class="text-muted">No technicians assigned.</p>'; foreach ($assigned as $assignment) echo '<div class="py-1">' . e($assignment['name']) . '</div>';
if (can('jobcards.assign')) { echo '<hr><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="assign"><select class="form-select mb-2" name="technician_id"><option value="">Select technician</option>'; foreach ($technicians as $technician) echo '<option value="' . (int)$technician['id'] . '">' . e($technician['name']) . '</option>'; echo '</select><button class="btn btn-outline-primary w-100">Assign</button></form>'; }
@@ -379,6 +380,15 @@ if ($route === 'jobcards') {
}
}
}
$search = trim(scalar_input($_GET['q'] ?? null));
$statusFilter = scalar_input($_GET['status_filter'] ?? null, 'open');
if (!in_array($statusFilter, ['open', 'in_progress', 'assigned', 'closed'], true)) $statusFilter = 'open';
$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); }
if ($statusFilter === 'closed') { $conditions[] = "j.status = 'closed'"; } elseif ($statusFilter === 'in_progress') { $conditions[] = "j.status = 'in_progress'"; } elseif ($statusFilter === 'assigned') { $conditions[] = "j.status = 'assigned'"; } else { $conditions[] = "j.status <> 'closed'"; }
$scopeJoin = '';
if ($user['role_name'] === 'Technician') { $scopeJoin = ' JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ?'; array_unshift($params, (int)$user['id']); }
if ($user['role_name'] === 'Technician') {
$clientListForJobcard = db()->prepare("SELECT DISTINCT c.id, c.name 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 c.status = 'active' ORDER BY c.name");
$clientListForJobcard->execute(['user' => $user['id']]);
@@ -386,19 +396,16 @@ if ($route === 'jobcards') {
} else {
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
}
if ($user['role_name'] === 'Technician') {
$jobcardList = db()->prepare('SELECT 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 JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user ORDER BY j.created_at DESC LIMIT 100');
$jobcardList->execute(['user' => $user['id']]);
$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->execute($params);
$jobcards = $jobcardList->fetchAll();
} else {
$jobcards = db()->query('SELECT 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 ORDER BY j.created_at DESC LIMIT 100')->fetchAll();
}
render_header('Jobcards');
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Jobcards</h1><p class="text-muted mb-0">Track requested work and operational status.</p></div>';
if (can('jobcards.manage')) echo '<button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-jobcard">New jobcard</button>';
echo '</div>';
if (isset($_GET['created'])) echo '<div class="alert alert-success">Jobcard created successfully.</div>';
if ($errors) echo '<div class="alert alert-danger">' . e(implode(' ', $errors)) . '</div>';
echo '<form class="row g-2 mb-4" method="get"><input type="hidden" name="route" value="jobcards"><div class="col-md-6"><input class="form-control" name="q" value="' . e($search) . '" placeholder="Search reference, client or requested work"></div><div class="col-md-3"><select class="form-select" name="status_filter"><option value="open"' . ($statusFilter === 'open' ? ' selected' : '') . '>Open jobcards</option><option value="in_progress"' . ($statusFilter === 'in_progress' ? ' selected' : '') . '>In progress</option><option value="assigned"' . ($statusFilter === 'assigned' ? ' selected' : '') . '>Assigned</option><option value="closed"' . ($statusFilter === 'closed' ? ' selected' : '') . '>Closed</option></select></div><div class="col-md-3"><button class="btn btn-outline-primary w-100">Search and filter</button></div></form>';
if (can('jobcards.manage')) { echo '<div class="collapse mb-4" id="new-jobcard"><div class="card"><div class="card-body"><h2 class="h5">Create jobcard</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-6"><label class="form-label" for="jobcard-client">Client</label><select class="form-select" id="jobcard-client" name="client_id" required><option value="">Choose client</option>'; foreach ($clients as $client) echo '<option value="' . (int)$client['id'] . '">' . e($client['name']) . '</option>'; echo '</select></div><div class="col-md-3"><label class="form-label" for="jobcard-priority">Priority</label><select class="form-select" id="jobcard-priority" name="priority"><option>low</option><option selected>normal</option><option>high</option><option>critical</option></select></div><div class="col-12"><label class="form-label" for="work-requested">Work requested</label><textarea class="form-control" id="work-requested" name="work_requested" rows="4" maxlength="10000" required></textarea></div><div class="col-12"><button class="btn btn-primary">Create jobcard</button></div></form></div></div></div>'; }
echo '<div class="card"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Reference</th><th>Client</th><th>Priority</th><th>Status</th><th>Work requested</th><th>Created</th></tr></thead><tbody>';
if (!$jobcards) echo '<tr><td colspan="6" class="text-center text-muted py-4">No jobcards found.</td></tr>';
@@ -698,6 +705,24 @@ if ($route === 'reports') {
$format = scalar_input($_GET['format'] ?? null);
if ($format === 'csv') require_permission('reports.export');
try { $filters = \ReportFilters::fromArray($_GET); } catch (Throwable $exception) { http_response_code(400); exit('Invalid report filters'); }
$detail = scalar_input($_GET['detail'] ?? null) === '1';
if ($detail) {
$detailWhere = [];
$detailParams = [];
if ($filters->clientId !== null) { $detailWhere[] = 'c.id = ?'; $detailParams[] = $filters->clientId; }
if ($filters->status !== null) { $detailWhere[] = 'j.status = ?'; $detailParams[] = $filters->status; }
if ($filters->priority !== null) { $detailWhere[] = 'j.priority = ?'; $detailParams[] = $filters->priority; }
if ($filters->dateFrom !== null) { $detailWhere[] = 'te.work_date >= ?'; $detailParams[] = $filters->dateFrom; }
if ($filters->dateTo !== null) { $detailWhere[] = 'te.work_date <= ?'; $detailParams[] = $filters->dateTo; }
$detailScope = '';
if ($user['role_name'] === 'Technician') { $detailScope = 'JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ?'; array_unshift($detailParams, (int)$user['id']); }
$detailSql = 'SELECT j.reference_no, c.name AS client_name, j.created_at, j.status, j.work_requested, j.technician_notes, u.name AS technician_name, te.work_date, te.hours, te.notes AS hours_notes FROM jobcards j JOIN clients c ON c.id = j.client_id ' . $detailScope . ' LEFT JOIN time_entries te ON te.jobcard_id = j.id 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") LEFT JOIN users u ON u.id = te.technician_id' . ($detailWhere ? ' WHERE ' . implode(' AND ', $detailWhere) : '') . ' ORDER BY c.name, j.created_at DESC, te.work_date, te.id';
$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);
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; }
}
$where = [];
$params = [];
if ($filters->clientId !== null) { $where[] = 'c.id = ?'; $params[] = $filters->clientId; }
@@ -715,6 +740,9 @@ if ($route === 'reports') {
$reportStmt->execute($params);
$reportRows = $reportStmt->fetchAll();
$rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows);
$reportClients = $user['role_name'] === 'Technician' ? db()->prepare('SELECT DISTINCT c.id, c.name 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 ORDER BY c.name') : db()->query('SELECT id, name FROM clients ORDER BY name');
if ($reportClients instanceof PDOStatement) $reportClients->execute(['user' => $user['id']]);
$reportClients = $reportClients->fetchAll();
$filterQuery = http_build_query(array_filter(['client_id' => $filters->clientId, 'date_from' => $filters->dateFrom, 'date_to' => $filters->dateTo, 'technician_id' => $filters->technicianId, 'status' => $filters->status, 'priority' => $filters->priority], static fn($value): bool => $value !== null && $value !== ''));
if ($format === 'print') { header('Content-Type: text/html; charset=UTF-8'); header('Cache-Control: no-store'); echo (new \PrintReportRenderer())->render('Hours per client', ['Client', 'Jobcards', 'Hours'], $rows); exit; }
if ($format === 'csv') {
@@ -727,8 +755,8 @@ if ($route === 'reports') {
}
render_header('Reports');
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Reports</h1><p class="text-muted mb-0">Internal hours summary by client.</p></div><a class="btn btn-outline-secondary me-2" href="/?route=reports&format=print&' . e($filterQuery) . '">Print view</a>';
if (can('reports.export')) echo '<a class="btn btn-outline-primary" href="/?route=reports&format=csv&' . e($filterQuery) . '">Export CSV</a>';
echo '</div><form class="row g-2 mb-3" method="get"><input type="hidden" name="route" value="reports"><div class="col-md-3"><input class="form-control" type="number" min="1" name="client_id" placeholder="Client ID" value="' . e((string)($filters->clientId ?? '')) . '"></div><div class="col-md-3"><input class="form-control" type="date" name="date_from" value="' . e((string)($filters->dateFrom ?? '')) . '"></div><div class="col-md-3"><input class="form-control" type="date" name="date_to" value="' . e((string)($filters->dateTo ?? '')) . '"></div><div class="col-auto"><button class="btn btn-outline-secondary">Apply filters</button></div></form><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Client</th><th>Jobcards</th><th>Hours</th></tr></thead><tbody>';
if (can('reports.export')) echo '<a class="btn btn-outline-primary me-2" href="/?route=reports&format=xls&detail=1&' . e($filterQuery) . '">Download detailed XLS</a><a class="btn btn-outline-secondary" href="/?route=reports&detail=1&format=print&' . e($filterQuery) . '">Detailed print view</a>';
echo '</div><form class="row g-2 mb-3" method="get"><input type="hidden" name="route" value="reports"><div class="col-md-3"><select class="form-select" name="client_id"><option value="">All clients</option>'; foreach ($reportClients as $reportClient) echo '<option value="' . (int)$reportClient['id'] . '"' . ((int)($filters->clientId ?? 0) === (int)$reportClient['id'] ? ' selected' : '') . '>' . e($reportClient['name']) . '</option>'; echo '</select></div><div class="col-md-3"><input class="form-control" type="date" name="date_from" value="' . e((string)($filters->dateFrom ?? '')) . '"></div><div class="col-md-3"><input class="form-control" type="date" name="date_to" value="' . e((string)($filters->dateTo ?? '')) . '"></div><div class="col-auto"><button class="btn btn-outline-secondary">Apply filters</button></div></form><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Client</th><th>Jobcards</th><th>Hours</th></tr></thead><tbody>';
if (!$reportRows) echo '<tr><td colspan="3" class="text-center text-muted py-4">No report data available.</td></tr>';
foreach ($reportRows as $row) echo '<tr><td>' . e($row['client_name']) . '</td><td>' . (int)$row['jobcards'] . '</td><td>' . e(number_format((float)$row['hours'], 2)) . '</td></tr>';
echo '</tbody></table></div></div>';
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
$schema = file_get_contents($root . '/database/schema.sql');
$upgrade = file_get_contents($root . '/database/upgrade.sql');
$front = file_get_contents($root . '/public/index.php');
if ($schema === false || $upgrade === false || $front === false) throw new RuntimeException('Fixtures must be readable.');
function jobcard_feature_assert(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); }
jobcard_feature_assert(str_contains($schema, "('jobcards.close', 'Close jobcards')"), 'Fresh schema must seed jobcards.close.');
jobcard_feature_assert(str_contains($upgrade, "('jobcards.close', 'Close jobcards')"), 'Upgrade must seed jobcards.close.');
jobcard_feature_assert(str_contains($front, "if (\$to === 'closed') require_permission('jobcards.close');"), 'Closing a jobcard must require jobcards.close.');
jobcard_feature_assert(str_contains($front, "name=\"q\"") && str_contains($front, "name=\"status_filter\""), 'Jobcards page must expose search and status filters.');
jobcard_feature_assert(str_contains($front, "j.status <> 'closed'"), 'Default Jobcards filter must exclude closed records.');
jobcard_feature_assert(str_contains($front, "j.status = 'closed'"), 'Closed filter must explicitly select closed records.');
jobcard_feature_assert(str_contains($front, 'All clients') && str_contains($front, '$reportClient'), 'Reports must use a client-name dropdown.');
jobcard_feature_assert(str_contains($front, 'format=xls&detail=1'), 'Reports must expose detailed XLS export.');
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");