diff --git a/app/Domain/Jobcard/StatusTransitionValidator.php b/app/Domain/Jobcard/StatusTransitionValidator.php index 063954a..f08e1ef 100644 --- a/app/Domain/Jobcard/StatusTransitionValidator.php +++ b/app/Domain/Jobcard/StatusTransitionValidator.php @@ -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 diff --git a/database/schema.sql b/database/schema.sql index 9b73b20..7a1db94 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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'), diff --git a/database/upgrade.sql b/database/upgrade.sql index de267c5..48a919d 100644 --- a/database/upgrade.sql +++ b/database/upgrade.sql @@ -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'), diff --git a/public/index.php b/public/index.php index bb8a871..c74635f 100644 --- a/public/index.php +++ b/public/index.php @@ -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 '
' . 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 '
'; } diff --git a/tests/JobcardSearchCloseReportTest.php b/tests/JobcardSearchCloseReportTest.php index 79f6012..c9f46ce 100644 --- a/tests/JobcardSearchCloseReportTest.php +++ b/tests/JobcardSearchCloseReportTest.php @@ -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.');