From 401917e59461ae6be4c4ba49d71aabb764b8f260 Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Tue, 1 Sep 2026 23:22:45 +0200 Subject: [PATCH] feat: use time entry notes for technician work updates --- public/index.php | 16 ++++++++-------- tests/JobcardSearchCloseReportTest.php | 3 ++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/public/index.php b/public/index.php index 175a12d..067444f 100644 --- a/public/index.php +++ b/public/index.php @@ -324,11 +324,11 @@ if ($route === 'jobcard') { $totalHours = array_sum(array_map(static fn (array $entry): float => (float)$entry['hours'], $timeEntries)); render_header('Jobcard ' . $jobcard['reference_no']); echo '
← Back to jobcards

' . e($jobcard['reference_no']) . '

' . e($jobcard['client_name']) . '

' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '
' . (isset($_GET['updated']) ? '
Jobcard updated.
' : '') . ($actionErrors ? '
' . e(implode(' ', $actionErrors)) . '
' : ''); - echo '

Work requested

' . nl2br(e($jobcard['work_requested'])) . '

Work performed and notes

'; - if (can('jobcards.internal_notes')) echo ''; - echo '

Time entries

' . e(number_format($totalHours, 2)) . ' hours
'; + echo '

Work requested

' . nl2br(e($jobcard['work_requested'])) . '

'; + if (can('jobcards.internal_notes')) echo '

Internal notes

'; + echo '

Time entries

' . e(number_format($totalHours, 2)) . ' hours
'; 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 '
'; } + if (can('time_entries.record')) { echo '
'; if ($user['role_name'] !== 'Technician') { echo '
'; } echo '
'; } echo '

Status

Assigned technicians

'; @@ -766,12 +766,12 @@ if ($route === 'reports') { 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'; + $detailSql = 'SELECT j.reference_no, c.name AS client_name, j.created_at, j.status, j.work_requested, te.notes AS technician_notes, u.name AS technician_name, te.work_date, te.hours 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); + $detailHeaders = ['Jobcard', 'Client', 'Created', 'Status', 'Work requested', 'Technician notes / Work performed', 'Technician', 'Work date', 'Hours']; + $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']], $detailRows); $totalReportHours = array_sum(array_map(static fn(array $row): float => (float)($row['hours'] ?? 0), $detailRows)); - $detailData[] = ['TOTAL HOURS', '', '', '', '', '', '', '', round($totalReportHours, 2), '']; + $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 ''; 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; } } diff --git a/tests/JobcardSearchCloseReportTest.php b/tests/JobcardSearchCloseReportTest.php index bde1687..79f6012 100644 --- a/tests/JobcardSearchCloseReportTest.php +++ b/tests/JobcardSearchCloseReportTest.php @@ -17,7 +17,8 @@ jobcard_feature_assert(str_contains($front, 'All clients') && str_contains($fron jobcard_feature_assert(str_contains($front, 'format=xls&detail=1'), 'Reports must expose detailed XLS export.'); 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.'); -foreach (['Work requested', 'Technician notes / Work performed', 'Hours notes'] as $heading) jobcard_feature_assert(str_contains($front, $heading), "Detailed report must include {$heading}."); +foreach (['Technician notes / Work performed', 'totalReportHours'] as $heading) jobcard_feature_assert(str_contains($front, $heading), "Detailed report must include {$heading}."); +jobcard_feature_assert(!str_contains($front, 'name=\"technician_notes\"') && !str_contains($front, 'Hours notes'), 'Jobcards must use time-entry technician notes only.'); 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.');