Add shared specialty stage stepper to all workspace shells.
Deploy Ladill Care / deploy (push) Successful in 35s

Lift Eye Care stage pills into a reusable action-bar component so every
module visit workspace shows config-driven stages with RBAC-gated Move CTAs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-19 16:13:29 +00:00
co-authored by Cursor
parent b44ed50fe7
commit be7bd8f433
13 changed files with 185 additions and 233 deletions
@@ -963,6 +963,8 @@ class SpecialtyModuleController extends Controller
'visitHistory' => $visitHistory,
'kpis' => $kpis,
'stages' => $shell->stages($module),
'specialtyStageFlow' => $shell->stageFlow($module),
'specialtyStageRoute' => $shell->stageAdvanceRoute($module),
'services' => $services,
'workspaceTabs' => $shell->workspaceTabs($module),
'actions' => $shell->actions($module),
@@ -9,6 +9,9 @@ use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Visit;
use App\Services\Care\BloodBank\BloodBankWorkflowService;
use App\Services\Care\Emergency\EmergencyWorkflowService;
use App\Services\Care\Ophthalmology\OphthalmologyWorkflowService;
use Illuminate\Support\Collection;
/**
@@ -43,6 +46,79 @@ class SpecialtyShellService
return is_array($stages) ? array_values($stages) : [];
}
/**
* Named route for advancing/setting specialty visit stage, if the module has one.
*/
public function stageAdvanceRoute(string $moduleKey): ?string
{
return match ($moduleKey) {
'dentistry' => 'care.specialty.dentistry.stage',
'emergency' => 'care.specialty.emergency.stage',
'blood_bank' => 'care.specialty.blood-bank.stage',
'ophthalmology' => 'care.specialty.ophthalmology.stage',
default => null,
};
}
/**
* Default stage progression for Move CTAs and action menus.
*
* @return array<string, array{next: string, label: string}>
*/
public function stageFlow(string $moduleKey): array
{
return match ($moduleKey) {
'emergency' => app(EmergencyWorkflowService::class)->stageFlow(),
'blood_bank' => app(BloodBankWorkflowService::class)->stageFlow(),
'ophthalmology' => app(OphthalmologyWorkflowService::class)->stageFlow(),
'dentistry' => [
'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'],
'chair' => ['next' => 'procedure', 'label' => 'Start procedure'],
'procedure' => ['next' => 'recovery', 'label' => 'Move to recovery'],
'recovery' => ['next' => 'completed', 'label' => 'Complete visit'],
],
default => $this->sequentialStageFlow($moduleKey),
};
}
/**
* Build next-stage map from configured stage order when no suite workflow exists.
*
* @return array<string, array{next: string, label: string}>
*/
protected function sequentialStageFlow(string $moduleKey): array
{
$stages = $this->stages($moduleKey);
$flow = [];
for ($i = 0, $count = count($stages); $i < $count; $i++) {
$code = (string) ($stages[$i]['code'] ?? '');
if ($code === '') {
continue;
}
$next = $stages[$i + 1] ?? null;
if ($next === null) {
$flow[$code] = ['next' => $code, 'label' => 'Completed'];
continue;
}
$nextCode = (string) ($next['code'] ?? '');
if ($nextCode === '') {
continue;
}
$nextLabel = (string) ($next['label'] ?? $nextCode);
$flow[$code] = [
'next' => $nextCode,
'label' => 'Move to '.mb_strtolower($nextLabel),
];
}
return $flow;
}
/**
* Catalog from config (authoritative template).
*
@@ -3,9 +3,6 @@
$payload = $requestRecord?->payload ?? [];
$issuePayload = $bloodBankIssue?->payload ?? [];
$inventoryPayload = $bloodBankInventory?->payload ?? [];
$currentStage = $workspaceVisit->specialty_stage;
$stageFlow = $bloodBankStageFlow ?? [];
$canManage = $canAdvanceStage ?? $canConsult ?? false;
$urgency = (string) ($payload['urgency'] ?? '');
$isHighUrgency = str_contains(strtolower($urgency), 'urgent')
|| str_contains(strtolower($urgency), 'emergency')
@@ -16,7 +13,7 @@
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-slate-900">Blood Bank overview</h3>
<p class="mt-0.5 text-xs text-slate-500">Open request, urgency, cross-match status, and visit stage.</p>
<p class="mt-0.5 text-xs text-slate-500">Open request, urgency, cross-match status, and issue summary.</p>
</div>
<div class="flex flex-wrap gap-3 text-sm">
<a href="{{ route('care.specialty.blood-bank.print', $workspaceVisit) }}" target="_blank" class="font-medium text-indigo-600">Print summary</a>
@@ -55,51 +52,6 @@
</div>
</div>
<div class="mt-4 rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Visit stage</p>
<p class="mt-1 text-sm font-medium text-slate-900">
{{ $currentStage ? ucfirst(str_replace('_', ' ', $currentStage)) : 'Not set' }}
</p>
<div class="mt-3 flex flex-wrap gap-2">
@foreach (($bloodBankStageCodes ?: ['request', 'crossmatch', 'issue', 'transfusion', 'completed']) as $stageCode)
@if ($canManage)
<form method="POST" action="{{ route('care.specialty.blood-bank.stage', $workspaceVisit) }}">
@csrf
<input type="hidden" name="stage" value="{{ $stageCode }}">
<button type="submit"
@class([
'rounded-lg px-2.5 py-1 text-xs font-semibold',
'bg-indigo-600 text-white' => $currentStage === $stageCode,
'border border-slate-200 bg-white text-slate-700 hover:bg-slate-50' => $currentStage !== $stageCode,
])>
{{ ucfirst(str_replace('_', ' ', $stageCode)) }}
</button>
</form>
@else
<span
@class([
'rounded-lg px-2.5 py-1 text-xs font-semibold',
'bg-indigo-600 text-white' => $currentStage === $stageCode,
'border border-slate-200 bg-white text-slate-400' => $currentStage !== $stageCode,
])
@if ($currentStage !== $stageCode) aria-disabled="true" title="View-only access" @endif
>
{{ ucfirst(str_replace('_', ' ', $stageCode)) }}
</span>
@endif
@endforeach
@if ($canManage && $currentStage && isset($stageFlow[$currentStage]) && $stageFlow[$currentStage]['next'] !== $currentStage)
<form method="POST" action="{{ route('care.specialty.blood-bank.stage', $workspaceVisit) }}">
@csrf
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
<button type="submit" class="rounded-lg bg-emerald-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-emerald-700">
{{ $stageFlow[$currentStage]['label'] }}
</button>
</form>
@endif
</div>
</div>
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt class="text-slate-500">Indication</dt>
@@ -1,70 +1,17 @@
@php
$plan = $dentalPlan;
$openItems = $plan?->items?->whereIn('status', ['proposed', 'accepted']) ?? collect();
$currentStage = $workspaceVisit->specialty_stage;
$canManage = $canConsult ?? false;
$stageFlow = [
'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'],
'chair' => ['next' => 'procedure', 'label' => 'Start procedure'],
'procedure' => ['next' => 'recovery', 'label' => 'Move to recovery'],
'recovery' => ['next' => 'completed', 'label' => 'Complete visit'],
];
@endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-slate-900">Overview</h3>
<p class="mt-0.5 text-xs text-slate-500">Visit stage, summary, and open treatment work.</p>
<p class="mt-0.5 text-xs text-slate-500">Visit summary and open treatment work.</p>
</div>
<a href="{{ route('care.specialty.dentistry.reports') }}" class="text-sm font-medium text-indigo-600">Dentistry reports</a>
</div>
<div class="mt-4 rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Visit stage</p>
<p class="mt-1 text-sm font-medium text-slate-900">
{{ $currentStage ? ucfirst(str_replace('_', ' ', $currentStage)) : 'Not set' }}
</p>
<div class="mt-3 flex flex-wrap gap-2">
@foreach (['waiting', 'chair', 'procedure', 'recovery', 'completed'] as $stageCode)
@if ($canManage)
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}">
@csrf
<input type="hidden" name="stage" value="{{ $stageCode }}">
<button type="submit"
@class([
'rounded-lg px-2.5 py-1 text-xs font-semibold',
'bg-indigo-600 text-white' => $currentStage === $stageCode,
'border border-slate-200 bg-white text-slate-700 hover:bg-slate-50' => $currentStage !== $stageCode,
])>
{{ ucfirst($stageCode) }}
</button>
</form>
@else
<span
@class([
'rounded-lg px-2.5 py-1 text-xs font-semibold',
'bg-indigo-600 text-white' => $currentStage === $stageCode,
'border border-slate-200 bg-white text-slate-400' => $currentStage !== $stageCode,
])
@if ($currentStage !== $stageCode) aria-disabled="true" title="View-only access" @endif
>
{{ ucfirst($stageCode) }}
</span>
@endif
@endforeach
@if ($canManage && $currentStage && isset($stageFlow[$currentStage]))
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}">
@csrf
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
<button type="submit" class="rounded-lg bg-emerald-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-emerald-700">
{{ $stageFlow[$currentStage]['label'] }}
</button>
</form>
@endif
</div>
</div>
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt class="text-slate-500">Visit status</dt>
@@ -1,17 +1,14 @@
@php
$triage = $emergencyTriage;
$payload = $triage?->payload ?? [];
$currentStage = $workspaceVisit->specialty_stage;
$stageFlow = $emergencyStageFlow ?? [];
$latest = $emergencyLatestVitals;
$canManage = $canConsult ?? false;
@endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-slate-900">Emergency overview</h3>
<p class="mt-0.5 text-xs text-slate-500">Acuity, stage, ABC flags, and latest vitals for this visit.</p>
<p class="mt-0.5 text-xs text-slate-500">Acuity, ABC flags, and latest vitals for this visit.</p>
</div>
<div class="flex flex-wrap gap-3 text-sm">
<a href="{{ route('care.specialty.emergency.print', $workspaceVisit) }}" target="_blank" class="font-medium text-indigo-600">Print summary</a>
@@ -49,51 +46,6 @@
</div>
</div>
<div class="mt-4 rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Visit stage</p>
<p class="mt-1 text-sm font-medium text-slate-900">
{{ $currentStage ? ucfirst(str_replace('_', ' ', $currentStage)) : 'Not set' }}
</p>
<div class="mt-3 flex flex-wrap gap-2">
@foreach (($emergencyStageCodes ?: ['arrival', 'resus', 'treatment', 'observation', 'disposition']) as $stageCode)
@if ($canManage)
<form method="POST" action="{{ route('care.specialty.emergency.stage', $workspaceVisit) }}">
@csrf
<input type="hidden" name="stage" value="{{ $stageCode }}">
<button type="submit"
@class([
'rounded-lg px-2.5 py-1 text-xs font-semibold',
'bg-indigo-600 text-white' => $currentStage === $stageCode,
'border border-slate-200 bg-white text-slate-700 hover:bg-slate-50' => $currentStage !== $stageCode,
])>
{{ ucfirst(str_replace('_', ' ', $stageCode)) }}
</button>
</form>
@else
<span
@class([
'rounded-lg px-2.5 py-1 text-xs font-semibold',
'bg-indigo-600 text-white' => $currentStage === $stageCode,
'border border-slate-200 bg-white text-slate-400' => $currentStage !== $stageCode,
])
@if ($currentStage !== $stageCode) aria-disabled="true" title="View-only access" @endif
>
{{ ucfirst(str_replace('_', ' ', $stageCode)) }}
</span>
@endif
@endforeach
@if ($canManage && $currentStage && isset($stageFlow[$currentStage]) && $stageFlow[$currentStage]['next'] !== $currentStage)
<form method="POST" action="{{ route('care.specialty.emergency.stage', $workspaceVisit) }}">
@csrf
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
<button type="submit" class="rounded-lg bg-emerald-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-emerald-700">
{{ $stageFlow[$currentStage]['label'] }}
</button>
</form>
@endif
</div>
</div>
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt class="text-slate-500">Mode of arrival</dt>
@@ -2,10 +2,6 @@
$examPayload = $ophthalmologyExam?->payload ?? [];
$refractionPayload = $ophthalmologyRefraction?->payload ?? [];
$planPayload = $ophthalmologyPlan?->payload ?? [];
$currentStage = $workspaceVisit->specialty_stage;
$stageFlow = $ophthalmologyStageFlow ?? [];
$stageLabels = collect($stages ?? [])->pluck('label', 'code');
$canManage = $canAdvanceStage ?? $canConsult ?? false;
$vaOd = $refractionPayload['va_od'] ?? ($examPayload['va_od'] ?? null);
$vaOs = $refractionPayload['va_os'] ?? ($examPayload['va_os'] ?? null);
@endphp
@@ -14,7 +10,7 @@
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-slate-900">Eye care overview</h3>
<p class="mt-0.5 text-xs text-slate-500">Visual acuity, IOP, diagnosis, and visit stage for this episode.</p>
<p class="mt-0.5 text-xs text-slate-500">Visual acuity, IOP, diagnosis, and visit summary for this episode.</p>
</div>
<div class="flex flex-wrap gap-3 text-sm">
<a href="{{ route('care.specialty.ophthalmology.print', $workspaceVisit) }}" target="_blank" class="font-medium text-indigo-600">Print summary</a>
@@ -44,51 +40,6 @@
</div>
</div>
<div class="mt-4 rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Visit stage</p>
<p class="mt-1 text-sm font-medium text-slate-900">
{{ $currentStage ? ($stageLabels[$currentStage] ?? ucfirst(str_replace('_', ' ', $currentStage))) : 'Not set' }}
</p>
<div class="mt-3 flex flex-wrap gap-2">
@foreach (($ophthalmologyStageCodes ?: ['check_in', 'history', 'refraction', 'exam', 'investigation', 'plan', 'treatment', 'completed']) as $stageCode)
@if ($canManage)
<form method="POST" action="{{ route('care.specialty.ophthalmology.stage', $workspaceVisit) }}">
@csrf
<input type="hidden" name="stage" value="{{ $stageCode }}">
<button type="submit"
@class([
'rounded-lg px-2.5 py-1 text-xs font-semibold',
'bg-indigo-600 text-white' => $currentStage === $stageCode,
'border border-slate-200 bg-white text-slate-700 hover:bg-slate-50' => $currentStage !== $stageCode,
])>
{{ $stageLabels[$stageCode] ?? ucfirst(str_replace('_', ' ', $stageCode)) }}
</button>
</form>
@else
<span
@class([
'rounded-lg px-2.5 py-1 text-xs font-semibold',
'bg-indigo-600 text-white' => $currentStage === $stageCode,
'border border-slate-200 bg-white text-slate-400' => $currentStage !== $stageCode,
])
@if ($currentStage !== $stageCode) aria-disabled="true" title="View-only access" @endif
>
{{ $stageLabels[$stageCode] ?? ucfirst(str_replace('_', ' ', $stageCode)) }}
</span>
@endif
@endforeach
@if ($canManage && $currentStage && isset($stageFlow[$currentStage]) && $stageFlow[$currentStage]['next'] !== $currentStage)
<form method="POST" action="{{ route('care.specialty.ophthalmology.stage', $workspaceVisit) }}">
@csrf
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
<button type="submit" class="rounded-lg bg-emerald-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-emerald-700">
{{ $stageFlow[$currentStage]['label'] }}
</button>
</form>
@endif
</div>
</div>
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt class="text-slate-500">Key findings</dt>
@@ -36,43 +36,20 @@
'blood_bank' => 'requests',
default => 'odontogram',
};
$stageRoute = match ($moduleKey) {
'dentistry' => 'care.specialty.dentistry.stage',
'emergency' => 'care.specialty.emergency.stage',
'blood_bank' => 'care.specialty.blood-bank.stage',
default => null,
};
$stageFlow = match ($moduleKey) {
'emergency' => ($emergencyStageFlow ?? [
'arrival' => ['next' => 'treatment', 'label' => 'Move to treatment'],
'resus' => ['next' => 'treatment', 'label' => 'Move to treatment bay'],
'treatment' => ['next' => 'observation', 'label' => 'Move to observation'],
'observation' => ['next' => 'disposition', 'label' => 'Ready for disposition'],
]),
'blood_bank' => ($bloodBankStageFlow ?? [
'request' => ['next' => 'crossmatch', 'label' => 'Start cross-match'],
'crossmatch' => ['next' => 'issue', 'label' => 'Ready to issue'],
'issue' => ['next' => 'transfusion', 'label' => 'Start transfusion'],
'transfusion' => ['next' => 'completed', 'label' => 'Complete visit'],
]),
'dentistry' => [
'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'],
'chair' => ['next' => 'procedure', 'label' => 'Start procedure'],
'procedure' => ['next' => 'recovery', 'label' => 'Move to recovery'],
'recovery' => ['next' => 'completed', 'label' => 'Complete visit'],
],
default => [],
};
$stageRoute = $specialtyStageRoute ?? null;
$stageFlow = $specialtyStageFlow ?? [];
$defaultStartStage = match ($moduleKey) {
'emergency' => 'arrival',
'blood_bank' => 'request',
'dentistry' => 'chair',
default => null,
'ophthalmology' => 'check_in',
default => collect($stages ?? [])->pluck('code')->first(),
};
$defaultStartLabel = match ($moduleKey) {
'emergency' => 'Start triage',
'blood_bank' => 'Start request',
'dentistry' => 'Seat at chair',
'ophthalmology' => 'Start check-in',
default => 'Start',
};
$currentStage = $workspaceVisit?->specialty_stage;
@@ -29,6 +29,15 @@
@endif
</section>
@else
<x-care.specialty-stage-bar
:stages="$stages ?? []"
:current-stage="$workspaceVisit->specialty_stage"
:stage-flow="$specialtyStageFlow ?? []"
:stage-route="$specialtyStageRoute ?? null"
:visit="$workspaceVisit"
:can-advance="$canAdvanceStage ?? false"
/>
{{-- Module tabs --}}
<div class="flex flex-wrap gap-1 border-b border-slate-200 pb-2">
@foreach ($workspaceTabs as $tabKey => $tabLabel)
@@ -0,0 +1,76 @@
@props([
'stages' => [],
'currentStage' => null,
'stageFlow' => [],
'stageRoute' => null,
'visit' => null,
'canAdvance' => false,
])
@php
$stageList = collect($stages)
->filter(fn ($stage) => is_array($stage) && ($stage['code'] ?? '') !== '')
->values();
$current = $currentStage ? (string) $currentStage : null;
$canMutate = (bool) $canAdvance && is_string($stageRoute) && $stageRoute !== '' && $visit;
$nextStep = ($current && isset($stageFlow[$current])) ? $stageFlow[$current] : null;
$canMoveNext = $canMutate
&& is_array($nextStep)
&& ($nextStep['next'] ?? null)
&& ($nextStep['next'] ?? null) !== $current;
@endphp
@if ($visit && $stageList->isNotEmpty())
<nav
{{ $attributes->merge([
'class' => 'rounded-2xl border border-slate-200 bg-white px-3 py-3 sm:px-4',
'aria-label' => 'Visit stages',
'data-care-stage-bar' => '1',
]) }}
>
<div class="flex flex-wrap gap-2">
@foreach ($stageList as $stage)
@php
$code = (string) $stage['code'];
$label = (string) ($stage['label'] ?? ucfirst(str_replace('_', ' ', $code)));
$isActive = $current === $code;
$pillBase = 'inline-flex items-center rounded-full px-3 py-1.5 text-xs font-semibold transition';
$pillActive = $pillBase.' bg-violet-700 text-white shadow-sm';
$pillIdle = $pillBase.' border border-slate-200 bg-white text-slate-800 hover:bg-slate-50';
$pillReadonly = $pillBase.' border border-slate-200 bg-white text-slate-800';
@endphp
@if ($canMutate)
<form method="POST" action="{{ route($stageRoute, $visit) }}" class="inline">
@csrf
<input type="hidden" name="stage" value="{{ $code }}">
<button type="submit" @class([$pillActive => $isActive, $pillIdle => ! $isActive])>
{{ $label }}
</button>
</form>
@else
<span
@class([$pillActive => $isActive, $pillReadonly => ! $isActive])
@if (! $isActive) title="View-only access" @endif
>
{{ $label }}
</span>
@endif
@endforeach
@if ($canMoveNext)
<form method="POST" action="{{ route($stageRoute, $visit) }}" class="inline">
@csrf
<input type="hidden" name="stage" value="{{ $nextStep['next'] }}">
<button
type="submit"
class="inline-flex items-center rounded-full bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-emerald-700"
data-care-stage-move
>
{{ $nextStep['label'] }}
</button>
</form>
@endif
</div>
</nav>
@endif
+3 -1
View File
@@ -127,7 +127,9 @@ class CareBloodBankSuiteTest extends TestCase
]))
->assertOk()
->assertSee('Blood Bank overview')
->assertSee('Visit stage')
->assertSee('data-care-stage-bar', false)
->assertSee('Request received')
->assertSee('Start cross-match')
->assertSee('BB reports');
$this->actingAs($this->owner)
+3
View File
@@ -132,6 +132,9 @@ class CareDentistrySuiteTest extends TestCase
'tab' => 'odontogram',
]))
->assertOk()
->assertSee('data-care-stage-bar', false)
->assertSee('Waiting room')
->assertSee('Seat at chair')
->assertSee('Odontogram (FDI)')
->assertSee('18')
->assertSee('Save tooth')
+3 -1
View File
@@ -128,7 +128,9 @@ class CareEmergencySuiteTest extends TestCase
]))
->assertOk()
->assertSee('Emergency overview')
->assertSee('Visit stage')
->assertSee('data-care-stage-bar', false)
->assertSee('Arrival / triage')
->assertSee('Move to treatment')
->assertSee('ED reports');
$this->actingAs($this->owner)
+4 -1
View File
@@ -127,7 +127,10 @@ class CareOphthalmologySuiteTest extends TestCase
]))
->assertOk()
->assertSee('Eye care overview')
->assertSee('Visit stage')
->assertSee('data-care-stage-bar', false)
->assertSee('Check-in')
->assertSee('Diagnosis & plan')
->assertSee('Start history')
->assertSee('Eye care reports');
$this->actingAs($this->owner)