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_client' => ['in_progress', 'completed'],
'awaiting_parts' => ['in_progress', 'completed'], 'awaiting_parts' => ['in_progress', 'completed'],
'completed' => ['closed'], 'completed' => ['closed'],
'closed' => [], 'closed' => ['in_progress'],
]; ];
public function canTransition(string $from, string $to): bool 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.view', 'View jobcards'),
('jobcards.manage', 'Create and update jobcards'), ('jobcards.manage', 'Create and update jobcards'),
('jobcards.close', 'Close jobcards'), ('jobcards.close', 'Close jobcards'),
('jobcards.reopen', 'Reopen closed 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'),
+1
View File
@@ -92,6 +92,7 @@ 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'), ('jobcards.close', 'Close jobcards'),
('jobcards.reopen', 'Reopen closed 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'),
+3 -1
View File
@@ -209,9 +209,11 @@ if ($route === 'jobcard') {
$lockedStmt->execute(['id' => $jobcardId]); $lockedStmt->execute(['id' => $jobcardId]);
$locked = $lockedStmt->fetch(); $locked = $lockedStmt->fetch();
if (!$locked) throw new RuntimeException('Jobcard no longer exists.'); 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'); $now = date('Y-m-d H:i:s');
$completedAt = $to === 'completed' ? $now : ($locked['completed_at'] ?: null); $completedAt = $to === 'completed' ? $now : ($locked['completed_at'] ?: null);
$closedAt = $to === 'closed' ? $now : ($locked['closed_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; if ($to === 'closed' && $completedAt === null) $completedAt = $now;
$transition = (new \App\Domain\Jobcard\JobcardWorkflow())->validateTransition($locked['status'], $to, $completedAt, $closedAt); $transition = (new \App\Domain\Jobcard\JobcardWorkflow())->validateTransition($locked['status'], $to, $completedAt, $closedAt);
if (!$transition['valid']) { 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>'; 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>'; } 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">'; 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>'; 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>'; }
+7 -3
View File
@@ -5,10 +5,14 @@ $root = dirname(__DIR__);
$schema = file_get_contents($root . '/database/schema.sql'); $schema = file_get_contents($root . '/database/schema.sql');
$upgrade = file_get_contents($root . '/database/upgrade.sql'); $upgrade = file_get_contents($root . '/database/upgrade.sql');
$front = file_get_contents($root . '/public/index.php'); $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); } 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($schema, "('jobcards.reopen', 'Reopen closed jobcards')"), 'Fresh schema must seed jobcards.reopen.');
jobcard_feature_assert(str_contains($upgrade, "('jobcards.close', 'Close jobcards')"), 'Upgrade must seed jobcards.close.'); 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, "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, "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'"), 'Default Jobcards filter must exclude closed records.');