feat: add close permissions jobcard filters and detailed xls reports
This commit is contained in:
@@ -238,6 +238,7 @@ INSERT IGNORE INTO permissions (name, description) VALUES
|
|||||||
('clients.manage', 'Create and edit client records'),
|
('clients.manage', 'Create and edit client records'),
|
||||||
('jobcards.view', 'View jobcards'),
|
('jobcards.view', 'View jobcards'),
|
||||||
('jobcards.manage', 'Create and update jobcards'),
|
('jobcards.manage', 'Create and update jobcards'),
|
||||||
|
('jobcards.close', 'Close jobcards'),
|
||||||
('jobcards.assign', 'Assign technicians to jobcards'),
|
('jobcards.assign', 'Assign technicians to jobcards'),
|
||||||
('jobcards.internal_notes', 'View and edit internal jobcard notes'),
|
('jobcards.internal_notes', 'View and edit internal jobcard notes'),
|
||||||
('time_entries.record', 'Record technician time entries'),
|
('time_entries.record', 'Record technician time entries'),
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|||||||
INSERT IGNORE INTO permissions (name, description) VALUES
|
INSERT IGNORE INTO permissions (name, description) VALUES
|
||||||
('technical.view', 'View client technical information'),
|
('technical.view', 'View client technical information'),
|
||||||
('technical.manage', 'Manage client technical information'),
|
('technical.manage', 'Manage client technical information'),
|
||||||
|
('jobcards.close', 'Close jobcards'),
|
||||||
('credentials.view', 'View protected credentials'),
|
('credentials.view', 'View protected credentials'),
|
||||||
('credentials.manage', 'Manage protected credentials'),
|
('credentials.manage', 'Manage protected credentials'),
|
||||||
('attachments.view', 'View jobcard attachments'),
|
('attachments.view', 'View jobcard attachments'),
|
||||||
|
|||||||
+38
-10
@@ -196,6 +196,7 @@ if ($route === 'jobcard') {
|
|||||||
if ($action === 'status') {
|
if ($action === 'status') {
|
||||||
require_permission('jobcards.manage');
|
require_permission('jobcards.manage');
|
||||||
$to = scalar_input($_POST['status'] ?? null);
|
$to = scalar_input($_POST['status'] ?? null);
|
||||||
|
if ($to === 'closed') require_permission('jobcards.close');
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
try {
|
try {
|
||||||
$pdo->beginTransaction();
|
$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>';
|
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>'; }
|
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">';
|
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>';
|
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 (!$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>'; }
|
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') {
|
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 = 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']]);
|
$clientListForJobcard->execute(['user' => $user['id']]);
|
||||||
@@ -386,19 +396,16 @@ if ($route === 'jobcards') {
|
|||||||
} else {
|
} else {
|
||||||
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
|
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
|
||||||
}
|
}
|
||||||
if ($user['role_name'] === 'Technician') {
|
$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 = 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($params);
|
||||||
$jobcardList->execute(['user' => $user['id']]);
|
$jobcards = $jobcardList->fetchAll();
|
||||||
$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');
|
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>';
|
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>';
|
if (can('jobcards.manage')) echo '<button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-jobcard">New jobcard</button>';
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
if (isset($_GET['created'])) echo '<div class="alert alert-success">Jobcard created successfully.</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>';
|
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>'; }
|
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>';
|
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>';
|
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);
|
$format = scalar_input($_GET['format'] ?? null);
|
||||||
if ($format === 'csv') require_permission('reports.export');
|
if ($format === 'csv') require_permission('reports.export');
|
||||||
try { $filters = \ReportFilters::fromArray($_GET); } catch (Throwable $exception) { http_response_code(400); exit('Invalid report filters'); }
|
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 = [];
|
$where = [];
|
||||||
$params = [];
|
$params = [];
|
||||||
if ($filters->clientId !== null) { $where[] = 'c.id = ?'; $params[] = $filters->clientId; }
|
if ($filters->clientId !== null) { $where[] = 'c.id = ?'; $params[] = $filters->clientId; }
|
||||||
@@ -715,6 +740,9 @@ if ($route === 'reports') {
|
|||||||
$reportStmt->execute($params);
|
$reportStmt->execute($params);
|
||||||
$reportRows = $reportStmt->fetchAll();
|
$reportRows = $reportStmt->fetchAll();
|
||||||
$rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows);
|
$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 !== ''));
|
$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 === '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') {
|
if ($format === 'csv') {
|
||||||
@@ -727,8 +755,8 @@ if ($route === 'reports') {
|
|||||||
}
|
}
|
||||||
render_header('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>';
|
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>';
|
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"><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>';
|
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>';
|
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>';
|
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>';
|
echo '</tbody></table></div></div>';
|
||||||
|
|||||||
@@ -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");
|
||||||
Reference in New Issue
Block a user