Assigned technicians
';
if (!$assigned) echo '
No technicians assigned.
'; foreach ($assigned as $assignment) echo '
' . e($assignment['name']) . '
';
if (can('jobcards.assign')) { echo '
'; }
@@ -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']]);
- $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();
- }
+ $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();
render_header('Jobcards');
echo '
Jobcards
Track requested work and operational status.
';
if (can('jobcards.manage')) echo '
';
echo '
';
if (isset($_GET['created'])) echo '
Jobcard created successfully.
';
if ($errors) echo '
' . e(implode(' ', $errors)) . '
';
+ echo '
';
if (can('jobcards.manage')) { echo '
'; }
echo '
| Reference | Client | Priority | Status | Work requested | Created |
';
if (!$jobcards) echo '| No jobcards found. |
';
@@ -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 ''; 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; }
+ }
$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 'Reports
Internal hours summary by client.
Print view';
- if (can('reports.export')) echo '
Export CSV';
- echo '
| Client | Jobcards | Hours |
';
+ if (can('reports.export')) echo 'Download detailed XLSDetailed print view';
+ echo '| Client | Jobcards | Hours |
';
if (!$reportRows) echo '| No report data available. |
';
foreach ($reportRows as $row) echo '| ' . e($row['client_name']) . ' | ' . (int)$row['jobcards'] . ' | ' . e(number_format((float)$row['hours'], 2)) . ' |
';
echo '
';
diff --git a/tests/JobcardSearchCloseReportTest.php b/tests/JobcardSearchCloseReportTest.php
new file mode 100644
index 0000000..3454898
--- /dev/null
+++ b/tests/JobcardSearchCloseReportTest.php
@@ -0,0 +1,19 @@
+ '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");