Replace ambulance Call next queue with an EMS dispatch board.
Deploy Ladill Care / deploy (push) Successful in 35s

Home columns follow New call → Dispatched → On scene → Transport → Handover; walk-ins land as New call and Call next is disabled for this module.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-20 12:13:33 +00:00
co-authored by Cursor
parent b7aca6ee2b
commit 4e753e68c0
12 changed files with 419 additions and 59 deletions
@@ -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);
@@ -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,
@@ -0,0 +1,185 @@
<?php
namespace App\Services\Care\Ambulance;
use App\Models\Organization;
use App\Models\Visit;
use App\Services\Care\SpecialtyShellService;
use Illuminate\Support\Collection;
/**
* Stage-grouped EMS dispatch board (replaces Call next queue home for ambulance).
*/
class AmbulanceDispatchBoardService
{
public function __construct(
protected SpecialtyShellService $shell,
) {}
/**
* @param list<int>|null $practitionerScope
* @return array{
* columns: list<array{
* key: string,
* label: string,
* count: int,
* items: Collection<int, Visit>,
* 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<int>|null $practitionerScope
* @return Collection<int, Visit>
*/
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();
}
}
@@ -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'],
];
}
+7 -1
View File
@@ -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']);
}