diff --git a/app/Http/Controllers/Care/AmbulanceWorkspaceController.php b/app/Http/Controllers/Care/AmbulanceWorkspaceController.php index 3d961b2..b8dc44e 100644 --- a/app/Http/Controllers/Care/AmbulanceWorkspaceController.php +++ b/app/Http/Controllers/Care/AmbulanceWorkspaceController.php @@ -45,7 +45,7 @@ class AmbulanceWorkspaceController extends Controller SpecialtyVisitStageService $stages, SpecialtyShellService $shell, ): RedirectResponse { - $this->authorizeAbility($request, 'consultations.manage'); + $this->authorizeAbility($request, 'ambulance.manage'); $this->assertAmbulanceManage($request, $modules); $this->assertVisit($request, $visit); diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 2da08fc..2aab644 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -9,6 +9,7 @@ use App\Models\Department; use App\Models\InvestigationType; use App\Models\PatientAttachment; use App\Models\Visit; +use App\Services\Care\Ambulance\AmbulanceDispatchBoardService; use App\Services\Care\AppointmentService; use App\Services\Care\CareQueueBridge; use App\Services\Care\CareQueueSessionAdvance; @@ -1518,17 +1519,22 @@ class SpecialtyModuleController extends Controller $canAdvanceStage = $canConsult || ($module === 'blood_bank' && $canManageSpecialty - && $permissions->can($member, 'blood_bank.manage')); + && $permissions->can($member, 'blood_bank.manage')) + || ($module === 'ambulance' + && $canManageSpecialty + && $permissions->can($member, 'ambulance.manage')); $canStartConsultation = $canConsult; // Same ability as GP consultation chart — not consultations.manage alone. $canPrescribe = $permissions->can($member, 'prescriptions.manage') && $canManageSpecialty; - $canCallNext = $canManageSpecialty; + // Ambulance is dispatch-out EMS — not a desk Call next line. + $canCallNext = $module !== 'ambulance' && $canManageSpecialty; $canManageClinical = $canManageSpecialty; $canManageVitals = $canManageSpecialty && ( $permissions->can($member, 'vitals.manage') || $permissions->can($member, 'consultations.manage') ); - $canManageQueue = $permissions->can($member, 'appointments.manage') && $canManageSpecialty; + $canManageQueue = ($permissions->can($member, 'appointments.manage') && $canManageSpecialty) + || ($module === 'ambulance' && $canManageSpecialty); $canAccessPatientQueue = $permissions->canAccessPatientQueue($member); $canBookAppointments = $permissions->can($member, 'appointments.manage'); $canViewPatients = $permissions->can($member, 'patients.view'); @@ -1537,6 +1543,16 @@ class SpecialtyModuleController extends Controller ? (string) (\App\Models\Branch::owned($owner)->whereKey($branchId)->value('name') ?? '') : ''; + $ambulanceDispatchBoard = null; + if ($module === 'ambulance' && in_array($section, ['visits', 'overview'], true)) { + $ambulanceDispatchBoard = app(AmbulanceDispatchBoardService::class)->board( + $organization, + $owner, + $kpiBranch, + $practitionerScope, + ); + } + $done = Appointment::owned($owner) ->where('organization_id', $organization->id) ->where('status', Appointment::STATUS_COMPLETED) @@ -2251,6 +2267,7 @@ class SpecialtyModuleController extends Controller 'ambulanceHandover' => $ambulanceHandover, 'ambulanceStageCodes' => $ambulanceStageCodes, 'ambulanceStageFlow' => $ambulanceStageFlow, + 'ambulanceDispatchBoard' => $ambulanceDispatchBoard ?? null, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'branchLabel' => $branchLabel, diff --git a/app/Services/Care/Ambulance/AmbulanceDispatchBoardService.php b/app/Services/Care/Ambulance/AmbulanceDispatchBoardService.php new file mode 100644 index 0000000..3b43b2e --- /dev/null +++ b/app/Services/Care/Ambulance/AmbulanceDispatchBoardService.php @@ -0,0 +1,185 @@ +|null $practitionerScope + * @return array{ + * columns: list, + * empty: string, + * accent: string, + * dot: string, + * next_stage: ?string, + * next_label: ?string + * }>, + * active_count: int, + * completed_today: int + * } + */ + public function board( + Organization $organization, + string $ownerRef, + ?int $branchScope = null, + ?array $practitionerScope = null, + ): array { + $flow = app(AmbulanceWorkflowService::class)->stageFlow(); + $open = $this->shell->openVisits($organization, 'ambulance', $ownerRef, $branchScope, $practitionerScope, 80); + $completed = $this->completedToday($organization, $ownerRef, $branchScope, $practitionerScope); + + $byStage = [ + AmbulanceWorkflowService::STAGE_CHECK_IN => collect(), + AmbulanceWorkflowService::STAGE_DISPATCH => collect(), + AmbulanceWorkflowService::STAGE_ON_SCENE => collect(), + AmbulanceWorkflowService::STAGE_TRANSPORT => collect(), + AmbulanceWorkflowService::STAGE_HANDOVER => collect(), + AmbulanceWorkflowService::STAGE_COMPLETED => $completed, + ]; + + foreach ($open as $visit) { + $stage = $visit->specialty_stage; + if ($stage === null || $stage === '' || $stage === AmbulanceWorkflowService::STAGE_CHECK_IN) { + $byStage[AmbulanceWorkflowService::STAGE_CHECK_IN]->push($visit); + } elseif (isset($byStage[$stage]) && $stage !== AmbulanceWorkflowService::STAGE_COMPLETED) { + $byStage[$stage]->push($visit); + } else { + // Unknown / unexpected stage — keep visible under New call. + $byStage[AmbulanceWorkflowService::STAGE_CHECK_IN]->push($visit); + } + } + + $meta = [ + AmbulanceWorkflowService::STAGE_CHECK_IN => [ + 'label' => 'New call', + 'empty' => 'No new calls.', + 'accent' => 'text-amber-700', + 'dot' => 'bg-amber-500', + ], + AmbulanceWorkflowService::STAGE_DISPATCH => [ + 'label' => 'Dispatched', + 'empty' => 'Nothing dispatched.', + 'accent' => 'text-indigo-700', + 'dot' => 'bg-indigo-500', + ], + AmbulanceWorkflowService::STAGE_ON_SCENE => [ + 'label' => 'On scene', + 'empty' => 'No crews on scene.', + 'accent' => 'text-sky-700', + 'dot' => 'bg-sky-500', + ], + AmbulanceWorkflowService::STAGE_TRANSPORT => [ + 'label' => 'Transport', + 'empty' => 'No patients in transport.', + 'accent' => 'text-violet-700', + 'dot' => 'bg-violet-500', + ], + AmbulanceWorkflowService::STAGE_HANDOVER => [ + 'label' => 'Handover', + 'empty' => 'No handovers in progress.', + 'accent' => 'text-emerald-700', + 'dot' => 'bg-emerald-500', + ], + AmbulanceWorkflowService::STAGE_COMPLETED => [ + 'label' => 'Completed today', + 'empty' => 'None completed today.', + 'accent' => 'text-slate-500', + 'dot' => 'bg-slate-300', + ], + ]; + + $columns = []; + foreach ($meta as $key => $column) { + $items = $byStage[$key] ?? collect(); + $next = $flow[$key]['next'] ?? null; + $nextLabel = $flow[$key]['label'] ?? null; + if ($key === AmbulanceWorkflowService::STAGE_COMPLETED) { + $next = null; + $nextLabel = null; + } + + $columns[] = [ + 'key' => $key, + 'label' => $column['label'], + 'count' => $items->count(), + 'items' => $items, + 'empty' => $column['empty'], + 'accent' => $column['accent'], + 'dot' => $column['dot'], + 'next_stage' => $next !== $key ? $next : null, + 'next_label' => $next !== $key ? $nextLabel : null, + ]; + } + + $activeCount = collect($columns) + ->reject(fn (array $col) => $col['key'] === AmbulanceWorkflowService::STAGE_COMPLETED) + ->sum('count'); + + return [ + 'columns' => $columns, + 'active_count' => $activeCount, + 'completed_today' => $completed->count(), + ]; + } + + /** + * @param list|null $practitionerScope + * @return Collection + */ + protected function completedToday( + Organization $organization, + string $ownerRef, + ?int $branchScope = null, + ?array $practitionerScope = null, + int $limit = 40, + ): Collection { + $departmentIds = $this->shell->departmentIds($organization, 'ambulance', $ownerRef, $branchScope); + + return Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where(function ($q) { + $q->where('specialty_stage', AmbulanceWorkflowService::STAGE_COMPLETED) + ->orWhere(function ($qq) { + $qq->where('status', Visit::STATUS_COMPLETED) + ->whereDate('completed_at', today()); + }); + }) + ->where(function ($q) { + $q->whereDate('completed_at', today()) + ->orWhere(function ($qq) { + $qq->whereNull('completed_at') + ->whereDate('updated_at', today()); + }); + }) + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->whereHas('appointment', function ($q) use ($departmentIds, $practitionerScope) { + $q->when($departmentIds !== [], fn ($qq) => $qq->whereIn('department_id', $departmentIds)) + ->when($departmentIds === [], fn ($qq) => $qq->whereRaw('1 = 0')) + ->when( + $practitionerScope !== null, + fn ($qq) => $qq->whereIn('practitioner_id', $practitionerScope ?: [0]), + ); + }) + ->with(['patient', 'appointment.practitioner', 'appointment.department']) + ->orderByDesc('completed_at') + ->orderByDesc('id') + ->limit($limit) + ->get(); + } +} diff --git a/app/Services/Care/Ambulance/AmbulanceWorkflowService.php b/app/Services/Care/Ambulance/AmbulanceWorkflowService.php index c761912..c10c07b 100644 --- a/app/Services/Care/Ambulance/AmbulanceWorkflowService.php +++ b/app/Services/Care/Ambulance/AmbulanceWorkflowService.php @@ -74,11 +74,11 @@ class AmbulanceWorkflowService public function stageFlow(): array { return [ - self::STAGE_CHECK_IN => ['next' => self::STAGE_DISPATCH, 'label' => 'Start dispatch'], - self::STAGE_DISPATCH => ['next' => self::STAGE_ON_SCENE, 'label' => 'Move to on scene'], - self::STAGE_ON_SCENE => ['next' => self::STAGE_TRANSPORT, 'label' => 'Move to transport'], - self::STAGE_TRANSPORT => ['next' => self::STAGE_HANDOVER, 'label' => 'Move to handover'], - self::STAGE_HANDOVER => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'], + self::STAGE_CHECK_IN => ['next' => self::STAGE_DISPATCH, 'label' => 'Dispatch'], + self::STAGE_DISPATCH => ['next' => self::STAGE_ON_SCENE, 'label' => 'On scene'], + self::STAGE_ON_SCENE => ['next' => self::STAGE_TRANSPORT, 'label' => 'Transport'], + self::STAGE_TRANSPORT => ['next' => self::STAGE_HANDOVER, 'label' => 'Handover'], + self::STAGE_HANDOVER => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete'], self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'], ]; } diff --git a/app/Services/Care/AppointmentService.php b/app/Services/Care/AppointmentService.php index b9fcddc..4f7c114 100644 --- a/app/Services/Care/AppointmentService.php +++ b/app/Services/Care/AppointmentService.php @@ -297,9 +297,15 @@ class AppointmentService AuditLogger::record($ownerRef, 'appointment.walk_in', $organization->id, $actorRef, Appointment::class, $appointment->id); - $appointment = $appointment->load(['patient', 'practitioner', 'branch', 'visit']); + $appointment = $appointment->load(['patient', 'practitioner', 'branch', 'visit', 'department']); $this->queueBridge->issueForAppointment($organization, $appointment); + if ($appointment->department?->type === 'ambulance' && $appointment->visit) { + $appointment->visit->update([ + 'specialty_stage' => \App\Services\Care\Ambulance\AmbulanceWorkflowService::STAGE_CHECK_IN, + ]); + } + return $appointment->fresh(['patient', 'practitioner', 'branch', 'visit']); } diff --git a/config/care_specialty_modules.php b/config/care_specialty_modules.php index 6436154..b39984c 100644 --- a/config/care_specialty_modules.php +++ b/config/care_specialty_modules.php @@ -476,7 +476,7 @@ return [ ], 'ambulance' => [ 'label' => 'Ambulance', - 'description' => 'EMS / ambulance clinic workflow: dispatch, scene triage, en-route care, and handover.', + 'description' => 'EMS / ambulance dispatch board: new calls, dispatch, scene, transport, and facility handover.', 'department_type' => 'ambulance', 'department_name' => 'Ambulance', 'queue_name' => 'Ambulance', diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index 0cea443..141ff57 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -877,8 +877,8 @@ return [ ], 'ambulance' => [ 'stages' => [ - ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], - ['code' => 'dispatch', 'label' => 'Dispatch', 'queue_point' => 'waiting'], + ['code' => 'check_in', 'label' => 'New call', 'queue_point' => 'waiting'], + ['code' => 'dispatch', 'label' => 'Dispatched', 'queue_point' => 'waiting'], ['code' => 'on_scene', 'label' => 'On scene', 'queue_point' => 'chair'], ['code' => 'transport', 'label' => 'Transport', 'queue_point' => 'chair'], ['code' => 'handover', 'label' => 'Handover', 'queue_point' => 'chair'], diff --git a/resources/views/care/specialty/ambulance/dispatch-board.blade.php b/resources/views/care/specialty/ambulance/dispatch-board.blade.php new file mode 100644 index 0000000..c347f24 --- /dev/null +++ b/resources/views/care/specialty/ambulance/dispatch-board.blade.php @@ -0,0 +1,48 @@ +{{-- + Ambulance / EMS dispatch board — stage columns, not Call next desk queue. + + Expected: $ambulanceDispatchBoard (from AmbulanceDispatchBoardService), + $canAdvanceStage, $specialtyStageRoute, $canManageQueue (for New call CTA context) +--}} +@php + $board = $ambulanceDispatchBoard ?? ['columns' => [], 'active_count' => 0, 'completed_today' => 0]; + $columns = $board['columns'] ?? []; + $canAdvanceStage = $canAdvanceStage ?? false; + $specialtyStageRoute = $specialtyStageRoute ?? 'care.specialty.ambulance.stage'; +@endphp + +
+
+

Dispatch board

+

Track calls from intake through dispatch, scene, transport, and facility handover — not a desk call-next line.

+
+ +
+ @foreach ($columns as $index => $column) +
$index < count($columns) - 1, + ])> +
+
+ +

{{ $column['label'] }}

+
+ {{ $column['count'] }} +
+
+ @forelse ($column['items'] as $visit) + @include('care.specialty.ambulance.dispatch-card', [ + 'visit' => $visit, + 'column' => $column, + 'canAdvanceStage' => $canAdvanceStage && $column['key'] !== 'completed', + 'specialtyStageRoute' => $specialtyStageRoute, + ]) + @empty +

{{ $column['empty'] }}

+ @endforelse +
+
+ @endforeach +
+
diff --git a/resources/views/care/specialty/ambulance/dispatch-card.blade.php b/resources/views/care/specialty/ambulance/dispatch-card.blade.php new file mode 100644 index 0000000..e15438b --- /dev/null +++ b/resources/views/care/specialty/ambulance/dispatch-card.blade.php @@ -0,0 +1,58 @@ +{{-- Single EMS call card on the ambulance dispatch board. --}} +@php + $appointment = $visit->appointment; + $patient = $visit->patient; + $openUrl = route('care.specialty.workspace', ['module' => 'ambulance', 'visit' => $visit]); + $ticketNumber = $appointment?->queue_ticket_number; + $reason = $appointment?->reason; + $nextStage = $column['next_stage'] ?? null; + $nextLabel = $column['next_label'] ?? null; + $isCompleted = ($column['key'] ?? '') === 'completed'; +@endphp +
($column['key'] ?? '') === 'check_in', + 'border-indigo-200 bg-indigo-50/50' => ($column['key'] ?? '') === 'dispatch', + 'border-sky-200 bg-sky-50/50' => ($column['key'] ?? '') === 'on_scene', + 'border-violet-200 bg-violet-50/50' => ($column['key'] ?? '') === 'transport', + 'border-emerald-200 bg-emerald-50/50' => ($column['key'] ?? '') === 'handover', + 'border-slate-100 bg-white opacity-90' => $isCompleted, + ]) +> +
+ @if ($ticketNumber) + {{ $ticketNumber }} + @endif +
+

{{ $patient?->fullName() ?? 'Patient' }}

+

+ {{ $patient?->patient_number ?? '—' }} + @if ($reason) + · {{ \Illuminate\Support\Str::limit($reason, 40) }} + @endif +

+ @if ($appointment?->practitioner?->name) +

{{ $appointment->practitioner->name }}

+ @endif +
+
+ +
+ Open + @if ($canAdvanceStage && $nextStage && $nextLabel) +
+ @csrf + + +
+ @endif +
+
diff --git a/resources/views/care/specialty/partials/actions-menu.blade.php b/resources/views/care/specialty/partials/actions-menu.blade.php index fd69767..3f787c2 100644 --- a/resources/views/care/specialty/partials/actions-menu.blade.php +++ b/resources/views/care/specialty/partials/actions-menu.blade.php @@ -83,7 +83,7 @@ $currentStage = $workspaceVisit?->specialty_stage; @endphp -@if ($onWorkspace && $canCallNext) +@if ($onWorkspace && $canCallNext && ($moduleKey ?? '') !== 'ambulance') @include('care.partials.queue-ops', [ 'queueIntegration' => $queueIntegration ?? null, 'queueCallNextRoute' => 'care.specialty.call-next', diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index 3275818..9695acd 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -3,8 +3,8 @@ 'workspace' => ($patientHeader['patient'] ?? null)?->fullName() ?: 'Workspace', 'history' => 'Visit history', 'billing' => 'Billing', - 'visits' => 'Patient queue', - default => 'Patient queue', + 'visits' => ($moduleKey ?? '') === 'ambulance' ? 'Dispatch board' : 'Patient queue', + default => ($moduleKey ?? '') === 'ambulance' ? 'Dispatch board' : 'Patient queue', }; @endphp @@ -197,66 +197,96 @@ @include('care.specialty.sections.billing') @else - {{-- Queue home (overview / visits) — same UI as GP Queue --}} + {{-- Queue / dispatch home --}}
@php $specialtyLabel = $definition['nav_label'] ?? $definition['label'] ?? ucfirst($moduleKey); + $isAmbulanceHome = $moduleKey === 'ambulance'; @endphp

{{ $specialtyLabel }}

- - @if ($canManageQueue ?? false) - - Walk-in - - @endif - + @if ($isAmbulanceHome) + + @if ($canManageQueue ?? false) + + New call + + @endif + + @else + + @if ($canManageQueue ?? false) + + Walk-in + + @endif + + @endif
History @if ($canManageClinical ?? $canManageSpecialty ?? true) Billing @endif + @if ($isAmbulanceHome) + Reports + @endif
- @include('care.partials.queue-board', [ - 'queue' => $queue, - 'inConsultation' => $inConsultation, - 'done' => $done ?? collect(), - 'queueIntegration' => $queueIntegration ?? null, - 'branchId' => $branchId, - 'branchLabel' => $branchLabel ?? null, - 'canConsult' => $canConsult ?? false, - 'lockToPractitioner' => $lockToPractitioner ?? false, - 'showFilters' => true, - 'queueCallNextRoute' => ($canCallNext ?? $canManageSpecialty ?? false) ? 'care.specialty.call-next' : null, - 'queueCallNextParams' => ['module' => $moduleKey], - 'startRouteName' => 'care.queue.start', - 'inCareLabel' => 'In care', - 'openInCareUrl' => function ($appointment) use ($moduleKey) { - if ($appointment->visit) { - return route('care.specialty.workspace', [ - 'module' => $moduleKey, - 'visit' => $appointment->visit, - ]); - } + @if ($isAmbulanceHome) + @include('care.specialty.ambulance.dispatch-board', [ + 'ambulanceDispatchBoard' => $ambulanceDispatchBoard ?? ['columns' => [], 'active_count' => 0, 'completed_today' => 0], + 'canAdvanceStage' => $canAdvanceStage ?? false, + 'specialtyStageRoute' => $specialtyStageRoute ?? 'care.specialty.ambulance.stage', + ]) + @else + @include('care.partials.queue-board', [ + 'queue' => $queue, + 'inConsultation' => $inConsultation, + 'done' => $done ?? collect(), + 'queueIntegration' => $queueIntegration ?? null, + 'branchId' => $branchId, + 'branchLabel' => $branchLabel ?? null, + 'canConsult' => $canConsult ?? false, + 'lockToPractitioner' => $lockToPractitioner ?? false, + 'showFilters' => true, + 'queueCallNextRoute' => ($canCallNext ?? $canManageSpecialty ?? false) ? 'care.specialty.call-next' : null, + 'queueCallNextParams' => ['module' => $moduleKey], + 'startRouteName' => 'care.queue.start', + 'inCareLabel' => 'In care', + 'openInCareUrl' => function ($appointment) use ($moduleKey) { + if ($appointment->visit) { + return route('care.specialty.workspace', [ + 'module' => $moduleKey, + 'visit' => $appointment->visit, + ]); + } - return $appointment->consultation - ? route('care.consultations.show', $appointment->consultation) - : null; - }, - ]) + return $appointment->consultation + ? route('care.consultations.show', $appointment->consultation) + : null; + }, + ]) + @endif
@include('care.partials.clinical-actions-panel', [ diff --git a/tests/Feature/CareAmbulanceSuiteTest.php b/tests/Feature/CareAmbulanceSuiteTest.php index 2cf8558..49ed0ea 100644 --- a/tests/Feature/CareAmbulanceSuiteTest.php +++ b/tests/Feature/CareAmbulanceSuiteTest.php @@ -129,7 +129,7 @@ class CareAmbulanceSuiteTest extends TestCase ->assertSee('Ambulance overview') ->assertSee('data-care-stage-bar', false) ->assertSee('Scene / triage') - ->assertSee('Start dispatch') + ->assertSee('Dispatch') ->assertSee('Ambulance reports'); $this->actingAs($this->owner) @@ -240,4 +240,20 @@ class CareAmbulanceSuiteTest extends TestCase ->assertOk() ->assertDontSee('Ambulance overview'); } + + public function test_home_shows_dispatch_board_not_call_next(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.show', 'ambulance')) + ->assertOk() + ->assertSee('Dispatch board') + ->assertSee('New call') + ->assertSee('Dispatched') + ->assertSee('On scene') + ->assertSee('Transport') + ->assertSee('Handover') + ->assertSee($this->patient->fullName()) + ->assertDontSee('Call next') + ->assertDontSee('Bring the next waiting patient to your desk'); + } }