feat: add permission to reopen closed jobcards

This commit is contained in:
Marco0300
2026-09-01 23:42:42 +02:00
parent 67fcfd6b8f
commit 8d46ef6a56
5 changed files with 13 additions and 5 deletions
@@ -14,7 +14,7 @@ final class StatusTransitionValidator
'awaiting_client' => ['in_progress', 'completed'],
'awaiting_parts' => ['in_progress', 'completed'],
'completed' => ['closed'],
'closed' => [],
'closed' => ['in_progress'],
];
public function canTransition(string $from, string $to): bool
+1
View File
@@ -247,6 +247,7 @@ INSERT IGNORE INTO permissions (name, description) VALUES
('jobcards.view', 'View jobcards'),
('jobcards.manage', 'Create and update jobcards'),
('jobcards.close', 'Close jobcards'),
('jobcards.reopen', 'Reopen closed 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
@@ -92,6 +92,7 @@ INSERT IGNORE INTO permissions (name, description) VALUES
('technical.view', 'View client technical information'),
('technical.manage', 'Manage client technical information'),
('jobcards.close', 'Close jobcards'),
('jobcards.reopen', 'Reopen closed jobcards'),
('credentials.view', 'View protected credentials'),
('credentials.manage', 'Manage protected credentials'),
('attachments.view', 'View jobcard attachments'),
+3 -1
View File
@@ -209,9 +209,11 @@ if ($route === 'jobcard') {
$lockedStmt->execute(['id' => $jobcardId]);
$locked = $lockedStmt->fetch();
if (!$locked) throw new RuntimeException('Jobcard no longer exists.');
if ($locked['status'] === 'closed' && $to !== 'closed') require_permission('jobcards.reopen');
$now = date('Y-m-d H:i:s');
$completedAt = $to === 'completed' ? $now : ($locked['completed_at'] ?: null);
$closedAt = $to === 'closed' ? $now : ($locked['closed_at'] ?: null);
if ($locked['status'] === 'closed' && $to === 'in_progress') { $completedAt = null; $closedAt = null; }
if ($to === 'closed' && $completedAt === null) $completedAt = $now;
$transition = (new \App\Domain\Jobcard\JobcardWorkflow())->validateTransition($locked['status'], $to, $completedAt, $closedAt);
if (!$transition['valid']) {
@@ -330,7 +332,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"><label class="form-label">Date performed</label><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"><label class="form-label">Technician notes / Work performed</label><textarea class="form-control" name="notes" rows="4" placeholder="Describe the work performed"></textarea></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) if ($status !== 'closed' || can('jobcards.close')) 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')) && ($jobcard['status'] !== 'closed' || $status === 'closed' || can('jobcards.reopen'))) 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>'; }
+7 -3
View File
@@ -5,10 +5,14 @@ $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.');
$validator = file_get_contents($root . '/app/Domain/Jobcard/StatusTransitionValidator.php');
if ($schema === false || $upgrade === false || $front === false || $validator === 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($schema, "('jobcards.reopen', 'Reopen closed jobcards')"), 'Fresh schema must seed jobcards.reopen.');
jobcard_feature_assert(str_contains($upgrade, "('jobcards.reopen', 'Reopen closed jobcards')"), 'Upgrade must seed jobcards.reopen.');
jobcard_feature_assert(str_contains($front, "if (\$locked['status'] === 'closed' && \$to !== 'closed') require_permission('jobcards.reopen');"), 'Reopening must require jobcards.reopen.');
jobcard_feature_assert(str_contains($validator, "'closed' => ['in_progress']"), 'Closed jobcards must support reopening to in progress.');
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.');