From 99aaaf3338dd851f80028afadb3fad959abc5322 Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Tue, 1 Sep 2026 22:59:40 +0200 Subject: [PATCH] feat: add close permissions jobcard filters and detailed xls reports --- database/schema.sql | 1 + database/upgrade.sql | 1 + public/index.php | 48 ++++++++++++++++++++------ tests/JobcardSearchCloseReportTest.php | 19 ++++++++++ 4 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 tests/JobcardSearchCloseReportTest.php diff --git a/database/schema.sql b/database/schema.sql index 8e66249..933bdb2 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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'), diff --git a/database/upgrade.sql b/database/upgrade.sql index ffd4853..a287baa 100644 --- a/database/upgrade.sql +++ b/database/upgrade.sql @@ -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'), diff --git a/public/index.php b/public/index.php index ff3e3fa..93789b4 100644 --- a/public/index.php +++ b/public/index.php @@ -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 '
' . e($entry['technician_name']) . ' · ' . e($entry['work_date']) . ' · ' . e(number_format((float)$entry['hours'], 2)) . 'h
' . e((string)($entry['notes'] ?? '')) . '
' . (can('time_entries.record') ? 'Correct or void' : '') . '
'; if (can('time_entries.record')) { echo '
'; if ($user['role_name'] !== 'Technician') { echo '
'; } echo '
'; } echo '

Status

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 '

Create jobcard

'; } echo '
'; if (!$jobcards) echo ''; @@ -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 '
ReferenceClientPriorityStatusWork requestedCreated
No jobcards found.
'; 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; } + } $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 '
'; + if (can('reports.export')) echo 'Download detailed XLSDetailed print view'; + echo '
ClientJobcardsHours
'; if (!$reportRows) echo ''; foreach ($reportRows as $row) echo ''; echo '
ClientJobcardsHours
No report data available.
' . e($row['client_name']) . '' . (int)$row['jobcards'] . '' . e(number_format((float)$row['hours'], 2)) . '
'; 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");