From 5bdc17777cb4cd9161c045e0a5b0b5603490f946 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 18 Jul 2026 16:03:38 +0000 Subject: [PATCH] Route all specialty patient flow through Queue. Remove Dentistry and other specialty modules from the sidebar; Queue now uses each doctor's specialty desk context for Call next, waiting lists, and Start into specialty clinical workspaces. Co-authored-by: Cursor --- app/Http/Controllers/Care/QueueController.php | 23 ++++++++-- .../Care/SpecialtyModuleController.php | 23 ++++++++-- app/Services/Care/AppointmentService.php | 31 +++++++++++-- resources/views/care/queue/index.blade.php | 32 ++++++++++++-- .../specialty/partials/actions-menu.blade.php | 2 +- .../specialty/sections/workspace.blade.php | 2 +- .../views/care/specialty/shell.blade.php | 6 +-- resources/views/partials/sidebar.blade.php | 43 +++---------------- tests/Feature/CareSpecialtyModulesTest.php | 13 ++---- tests/Feature/CareSpecialtyShellTest.php | 10 +---- 10 files changed, 110 insertions(+), 75 deletions(-) diff --git a/app/Http/Controllers/Care/QueueController.php b/app/Http/Controllers/Care/QueueController.php index 5644be5..2c8ecd7 100644 --- a/app/Http/Controllers/Care/QueueController.php +++ b/app/Http/Controllers/Care/QueueController.php @@ -46,6 +46,19 @@ class QueueController extends Controller : ($request->input('practitioner_id') ? (int) $request->input('practitioner_id') : null); $queueIntegration = ['enabled' => false]; + $queueContext = CareQueueContexts::CONSULTATION; + $specialtyLabel = null; + if ($lockToPractitioner && $practitionerId) { + $prac = Practitioner::owned($owner)->with('organization')->find($practitionerId); + $deskContext = $this->queueBridge->contextForPractitioner($organization, $prac); + if ($deskContext !== CareQueueContexts::CONSULTATION) { + $queueContext = $deskContext; + $specialtyLabel = (string) (config("care.specialty_modules.{$deskContext}.label") + ?? config("care.specialty_modules.{$deskContext}.nav_label") + ?? ucfirst(str_replace('_', ' ', $deskContext))); + } + } + if ($this->queueBridge->isEnabled($organization)) { $syncBranches = $lockToPractitioner ? Practitioner::owned($owner) @@ -63,11 +76,11 @@ class QueueController extends Controller } if ($branchId > 0) { - $queueIntegration = $this->queueBridge->listMeta($organization, CareQueueContexts::CONSULTATION, $branchId); + $queueIntegration = $this->queueBridge->listMeta($organization, $queueContext, $branchId); } elseif ($syncBranches !== []) { $queueIntegration = $this->queueBridge->listMeta( $organization, - CareQueueContexts::CONSULTATION, + $queueContext, (int) $syncBranches[0], ); } @@ -83,7 +96,7 @@ class QueueController extends Controller ->where('organization_id', $organization->id) ->where('status', Appointment::STATUS_IN_CONSULTATION) ->whereIn('practitioner_id', $practitionerScope ?: [0]) - ->with(['patient', 'practitioner', 'consultation']) + ->with(['patient', 'practitioner', 'consultation', 'visit', 'organization']) ->orderBy('started_at') ->get(); } else { @@ -95,7 +108,7 @@ class QueueController extends Controller ->where('status', Appointment::STATUS_IN_CONSULTATION) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) ->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId)) - ->with(['patient', 'practitioner', 'consultation']) + ->with(['patient', 'practitioner', 'consultation', 'visit', 'organization']) ->orderBy('started_at') ->get(); } @@ -139,6 +152,8 @@ class QueueController extends Controller 'canConsult' => $canConsult, 'heroStats' => $heroStats, 'queueIntegration' => $queueIntegration, + 'queueContext' => $queueContext, + 'specialtyLabel' => $specialtyLabel, ]); } diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index bccead3..7b11a79 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -29,8 +29,16 @@ class SpecialtyModuleController extends Controller SpecialtyModuleService $modules, SpecialtyShellService $shell, CareQueueBridge $queueBridge, - ): View { - return $this->renderShell($request, $module, 'overview', $modules, $shell, $queueBridge); + ): RedirectResponse { + $definition = $modules->definition($module); + abort_unless($definition, 404); + abort_unless( + $modules->memberCanAccess($this->organization($request), $this->member($request), $module), + 403, + ); + + // Specialty patient flow lives on Queue; clinical depth opens from Queue Start. + return redirect()->route('care.queue.index'); } public function visits( @@ -39,8 +47,15 @@ class SpecialtyModuleController extends Controller SpecialtyModuleService $modules, SpecialtyShellService $shell, CareQueueBridge $queueBridge, - ): View { - return $this->renderShell($request, $module, 'visits', $modules, $shell, $queueBridge); + ): RedirectResponse { + $definition = $modules->definition($module); + abort_unless($definition, 404); + abort_unless( + $modules->memberCanAccess($this->organization($request), $this->member($request), $module), + 403, + ); + + return redirect()->route('care.queue.index'); } public function history( diff --git a/app/Services/Care/AppointmentService.php b/app/Services/Care/AppointmentService.php index 78b20e4..195eae8 100644 --- a/app/Services/Care/AppointmentService.php +++ b/app/Services/Care/AppointmentService.php @@ -5,6 +5,7 @@ namespace App\Services\Care; use App\Models\Appointment; use App\Models\Organization; use App\Models\Patient; +use App\Models\Practitioner; use App\Models\Visit; use App\Services\Care\Workflow\WorkflowQueueGate; use App\Services\Care\Workflow\WorkflowStageCompletionService; @@ -153,17 +154,41 @@ class AppointmentService ->orderBy('waiting_at') ->orderBy('checked_in_at'); + $practitioners = Practitioner::owned($ownerRef) + ->whereIn('id', $practitionerIds) + ->get(); + + $allowedSpecialtyContexts = []; + $orgIds = $practitioners->pluck('organization_id')->filter()->unique()->all(); + $orgsById = \App\Models\Organization::query() + ->whereIn('id', $orgIds ?: [0]) + ->get() + ->keyBy('id'); + + foreach ($practitioners as $practitioner) { + $org = $orgsById->get((int) $practitioner->organization_id); + if (! $org) { + continue; + } + $deskContext = $this->queueBridge->contextForPractitioner($org, $practitioner); + if ($deskContext !== CareQueueContexts::CONSULTATION) { + $allowedSpecialtyContexts[$deskContext] = true; + } + } + $allowedSpecialtyContexts = array_keys($allowedSpecialtyContexts); + return new Collection( $query->get() - ->filter(function (Appointment $appointment) { + ->filter(function (Appointment $appointment) use ($allowedSpecialtyContexts) { $organization = $appointment->organization; if (! $organization) { return true; } $context = $this->queueBridge->contextForAppointment($organization, $appointment); - // Regular doctor Queue is consultation-only; specialty waiters belong on specialty modules. - if ($context !== CareQueueContexts::CONSULTATION) { + // GPs see consultation only. Specialty desks see their own specialty waiters on Queue. + if ($context !== CareQueueContexts::CONSULTATION + && ! in_array($context, $allowedSpecialtyContexts, true)) { return false; } diff --git a/resources/views/care/queue/index.blade.php b/resources/views/care/queue/index.blade.php index a113c88..84e0c63 100644 --- a/resources/views/care/queue/index.blade.php +++ b/resources/views/care/queue/index.blade.php @@ -1,12 +1,20 @@ +@php + $specialtyLabel = $specialtyLabel ?? null; + $queueBridge = app(\App\Services\Care\CareQueueBridge::class); +@endphp
@if ($canManageQueue) @@ -30,7 +38,23 @@ 'queueCallNextRoute' => 'care.queue.call-next', 'queueCallNextParams' => array_filter(['practitioner_id' => $practitionerId]), 'startRouteName' => 'care.queue.start', - 'inCareLabel' => 'In consultation', + 'inCareLabel' => $specialtyLabel ? 'In care' : 'In consultation', + 'openInCareUrl' => function ($appointment) use ($queueBridge) { + $organization = $appointment->organization; + if ($organization && $appointment->visit) { + $context = $queueBridge->contextForAppointment($organization, $appointment); + if ($context !== \App\Services\Care\CareQueueContexts::CONSULTATION) { + return route('care.specialty.workspace', [ + 'module' => $context, + 'visit' => $appointment->visit, + ]); + } + } + + return $appointment->consultation + ? route('care.consultations.show', $appointment->consultation) + : null; + }, ])
diff --git a/resources/views/care/specialty/partials/actions-menu.blade.php b/resources/views/care/specialty/partials/actions-menu.blade.php index a07809f..9c7d8fc 100644 --- a/resources/views/care/specialty/partials/actions-menu.blade.php +++ b/resources/views/care/specialty/partials/actions-menu.blade.php @@ -59,7 +59,7 @@ @endif @endif - + Back to queue diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index 854fd29..35f0153 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -16,7 +16,7 @@

No open visit selected

Open a visit from the queue, or call next from the waiting list.

- Back to queue + Back to queue
@else {{-- Module tabs --}} diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index 7b47089..e9a4a0a 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -11,7 +11,7 @@
@if ($section === 'workspace')
- ← Back to queue + ← Back to queue
History Billing @@ -37,7 +37,7 @@

Visit history

Completed encounters for this service line.

- ← Back to queue + ← Back to queue
@include('care.specialty.sections.history') @elseif ($section === 'billing') @@ -46,7 +46,7 @@

Billing

Service catalog for this module.

- ← Back to queue + ← Back to queue @include('care.specialty.sections.billing') @else diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 358bee8..fe88ff6 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -76,43 +76,12 @@ 'icon' => '']; } - // Always-on Pro/Enterprise surfaces first, then other specialty modules. - if ($permissions->handlesFloorCare($member)) { - $specialtyService = app(\App\Services\Care\SpecialtyModuleService::class); - if ($organization) { - $specialtyService->ensureDefaultModulesProvisioned( - $organization, - (string) $organization->owner_ref, - ); - $organization->refresh(); - } - $specialtyModules = $specialtyService->enabledModulesForMember($organization, $member); - $pinnedKeys = $specialtyService->defaultKeysForPaidPlans(); - usort($specialtyModules, function (array $a, array $b) use ($pinnedKeys): int { - $aPin = in_array($a['key'], $pinnedKeys, true) ? 0 : 1; - $bPin = in_array($b['key'], $pinnedKeys, true) ? 0 : 1; - if ($aPin !== $bPin) { - return $aPin <=> $bPin; - } - $aOrder = array_search($a['key'], $pinnedKeys, true); - $bOrder = array_search($b['key'], $pinnedKeys, true); - if ($aOrder !== false || $bOrder !== false) { - return ($aOrder === false ? 99 : $aOrder) <=> ($bOrder === false ? 99 : $bOrder); - } - - return strcasecmp( - (string) ($a['definition']['nav_label'] ?? $a['definition']['label'] ?? $a['key']), - (string) ($b['definition']['nav_label'] ?? $b['definition']['label'] ?? $b['key']), - ); - }); - foreach ($specialtyModules as $item) { - $nav[] = [ - 'name' => $item['definition']['nav_label'] ?? $item['definition']['label'], - 'route' => route('care.specialty.show', $item['key']), - 'active' => request()->routeIs('care.specialty.show') && request()->route('module') === $item['key'], - 'icon' => '', - ]; - } + // Specialty modules stay enabled for clinical depth, but patient flow lives on Queue only. + if ($permissions->handlesFloorCare($member) && $organization) { + app(\App\Services\Care\SpecialtyModuleService::class)->ensureDefaultModulesProvisioned( + $organization, + (string) $organization->owner_ref, + ); } } diff --git a/tests/Feature/CareSpecialtyModulesTest.php b/tests/Feature/CareSpecialtyModulesTest.php index 10dab60..37172fd 100644 --- a/tests/Feature/CareSpecialtyModulesTest.php +++ b/tests/Feature/CareSpecialtyModulesTest.php @@ -235,12 +235,7 @@ class CareSpecialtyModulesTest extends TestCase $this->actingAs($nurse) ->get(route('care.specialty.show', 'dentistry')) - ->assertOk() - ->assertSee('Patient queue') - ->assertSee('Waiting') - ->assertSee('In care') - ->assertDontSee('Specialty module') - ->assertDontSee('Stage map'); + ->assertRedirect(route('care.queue.index')); } public function test_unassigned_doctor_cannot_open_specialty_module(): void @@ -298,8 +293,7 @@ class CareSpecialtyModulesTest extends TestCase $this->actingAs($doctor) ->get(route('care.specialty.show', 'dentistry')) - ->assertOk() - ->assertSee('Dentistry'); + ->assertRedirect(route('care.queue.index')); $this->assertTrue( app(SpecialtyModuleService::class)->memberCanAccess( @@ -358,8 +352,7 @@ class CareSpecialtyModulesTest extends TestCase $this->actingAs($nurse) ->get(route('care.specialty.show', 'emergency')) - ->assertOk() - ->assertSee('Emergency'); + ->assertRedirect(route('care.queue.index')); $this->expectException(\RuntimeException::class); $service->deactivate($this->organization->fresh(), $this->owner->public_id, 'emergency'); diff --git a/tests/Feature/CareSpecialtyShellTest.php b/tests/Feature/CareSpecialtyShellTest.php index 94d7497..ad3d12c 100644 --- a/tests/Feature/CareSpecialtyShellTest.php +++ b/tests/Feature/CareSpecialtyShellTest.php @@ -96,17 +96,11 @@ class CareSpecialtyShellTest extends TestCase $this->actingAs($this->owner) ->get(route('care.specialty.show', 'emergency')) - ->assertOk() - ->assertSee('Patient queue') - ->assertSee('Waiting') - ->assertSee('In care') - ->assertDontSee('Specialty module') - ->assertDontSee('Stage map'); + ->assertRedirect(route('care.queue.index')); $this->actingAs($this->owner) ->get(route('care.specialty.visits', 'emergency')) - ->assertOk() - ->assertSee('Patient queue'); + ->assertRedirect(route('care.queue.index')); $this->actingAs($this->owner) ->get(route('care.specialty.history', 'emergency'))