Route all specialty patient flow through Queue.
Deploy Ladill Care / deploy (push) Successful in 37s
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:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
$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" />',
|
||||
];
|
||||
}
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user