Route all specialty patient flow through Queue.
Deploy Ladill Care / deploy (push) Successful in 37s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 16:03:38 +00:00
co-authored by Cursor
parent ec9135c59d
commit 5bdc17777c
10 changed files with 110 additions and 75 deletions
+19 -4
View File
@@ -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,
]);
}
@@ -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(
+28 -3
View File
@@ -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;
}
+28 -4
View File
@@ -1,12 +1,20 @@
@php
$specialtyLabel = $specialtyLabel ?? null;
$queueBridge = app(\App\Services\Care\CareQueueBridge::class);
@endphp
<x-app-layout title="Queue">
<div class="space-y-6">
<x-care.page-hero
badge="Waiting room · Consultations · Walk-ins"
:badge="$specialtyLabel
? $specialtyLabel.' · Waiting · Encounters'
: 'Waiting room · Consultations · Walk-ins'"
title="Patient queue"
description="See who is waiting, start consultations, and keep patient flow moving across your branch."
:description="$specialtyLabel
? 'See who is waiting for '.$specialtyLabel.', start encounters, and keep patient flow moving.'
: 'See who is waiting, start consultations, and keep patient flow moving across your branch.'"
:stats="[
['value' => number_format($heroStats['waiting']), 'label' => 'Waiting'],
['value' => number_format($heroStats['in_consultation']), 'label' => 'In consultation'],
['value' => number_format($heroStats['in_consultation']), 'label' => $specialtyLabel ? 'In care' : 'In consultation'],
['value' => number_format($heroStats['today']), 'label' => 'Today'],
]">
@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;
},
])
</div>
</x-app-layout>
@@ -59,7 +59,7 @@
@endif
@endif
<a href="{{ route('care.specialty.show', $moduleKey) }}" class="{{ $btnSecondary }}">
<a href="{{ route('care.queue.index') }}" class="{{ $btnSecondary }}">
Back to queue
</a>
@@ -16,7 +16,7 @@
<section class="rounded-2xl border border-dashed border-slate-200 bg-white px-4 py-12 text-center">
<h2 class="text-sm font-semibold text-slate-900">No open visit selected</h2>
<p class="mt-1 text-sm text-slate-500">Open a visit from the queue, or call next from the waiting list.</p>
<a href="{{ route('care.specialty.show', $moduleKey) }}" class="mt-4 inline-flex text-sm font-medium text-indigo-600">Back to queue</a>
<a href="{{ route('care.queue.index') }}" class="mt-4 inline-flex text-sm font-medium text-indigo-600">Back to queue</a>
</section>
@else
{{-- Module tabs --}}
@@ -11,7 +11,7 @@
<div class="space-y-4">
@if ($section === 'workspace')
<div class="flex flex-wrap items-center justify-between gap-3">
<a href="{{ route('care.specialty.show', $moduleKey) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Back to queue</a>
<a href="{{ route('care.queue.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Back to queue</a>
<div class="flex flex-wrap gap-3 text-sm">
<a href="{{ route('care.specialty.history', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">History</a>
<a href="{{ route('care.specialty.billing', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">Billing</a>
@@ -37,7 +37,7 @@
<h1 class="text-xl font-semibold text-slate-900">Visit history</h1>
<p class="mt-1 text-sm text-slate-500">Completed encounters for this service line.</p>
</div>
<a href="{{ route('care.specialty.show', $moduleKey) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Back to queue</a>
<a href="{{ route('care.queue.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Back to queue</a>
</div>
@include('care.specialty.sections.history')
@elseif ($section === 'billing')
@@ -46,7 +46,7 @@
<h1 class="text-xl font-semibold text-slate-900">Billing</h1>
<p class="mt-1 text-sm text-slate-500">Service catalog for this module.</p>
</div>
<a href="{{ route('care.specialty.show', $moduleKey) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Back to queue</a>
<a href="{{ route('care.queue.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Back to queue</a>
</div>
@include('care.specialty.sections.billing')
@else
+3 -34
View File
@@ -76,43 +76,12 @@
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />'];
}
// 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(
// 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,
);
$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' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />',
];
}
}
}
+3 -10
View File
@@ -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');
+2 -8
View File
@@ -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'))