Replace ambulance Call next queue with an EMS dispatch board.
Deploy Ladill Care / deploy (push) Successful in 35s
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:
@@ -45,7 +45,7 @@ class AmbulanceWorkspaceController extends Controller
|
|||||||
SpecialtyVisitStageService $stages,
|
SpecialtyVisitStageService $stages,
|
||||||
SpecialtyShellService $shell,
|
SpecialtyShellService $shell,
|
||||||
): RedirectResponse {
|
): RedirectResponse {
|
||||||
$this->authorizeAbility($request, 'consultations.manage');
|
$this->authorizeAbility($request, 'ambulance.manage');
|
||||||
$this->assertAmbulanceManage($request, $modules);
|
$this->assertAmbulanceManage($request, $modules);
|
||||||
$this->assertVisit($request, $visit);
|
$this->assertVisit($request, $visit);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\Department;
|
|||||||
use App\Models\InvestigationType;
|
use App\Models\InvestigationType;
|
||||||
use App\Models\PatientAttachment;
|
use App\Models\PatientAttachment;
|
||||||
use App\Models\Visit;
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\Ambulance\AmbulanceDispatchBoardService;
|
||||||
use App\Services\Care\AppointmentService;
|
use App\Services\Care\AppointmentService;
|
||||||
use App\Services\Care\CareQueueBridge;
|
use App\Services\Care\CareQueueBridge;
|
||||||
use App\Services\Care\CareQueueSessionAdvance;
|
use App\Services\Care\CareQueueSessionAdvance;
|
||||||
@@ -1518,17 +1519,22 @@ class SpecialtyModuleController extends Controller
|
|||||||
$canAdvanceStage = $canConsult
|
$canAdvanceStage = $canConsult
|
||||||
|| ($module === 'blood_bank'
|
|| ($module === 'blood_bank'
|
||||||
&& $canManageSpecialty
|
&& $canManageSpecialty
|
||||||
&& $permissions->can($member, 'blood_bank.manage'));
|
&& $permissions->can($member, 'blood_bank.manage'))
|
||||||
|
|| ($module === 'ambulance'
|
||||||
|
&& $canManageSpecialty
|
||||||
|
&& $permissions->can($member, 'ambulance.manage'));
|
||||||
$canStartConsultation = $canConsult;
|
$canStartConsultation = $canConsult;
|
||||||
// Same ability as GP consultation chart — not consultations.manage alone.
|
// Same ability as GP consultation chart — not consultations.manage alone.
|
||||||
$canPrescribe = $permissions->can($member, 'prescriptions.manage') && $canManageSpecialty;
|
$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;
|
$canManageClinical = $canManageSpecialty;
|
||||||
$canManageVitals = $canManageSpecialty && (
|
$canManageVitals = $canManageSpecialty && (
|
||||||
$permissions->can($member, 'vitals.manage')
|
$permissions->can($member, 'vitals.manage')
|
||||||
|| $permissions->can($member, 'consultations.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);
|
$canAccessPatientQueue = $permissions->canAccessPatientQueue($member);
|
||||||
$canBookAppointments = $permissions->can($member, 'appointments.manage');
|
$canBookAppointments = $permissions->can($member, 'appointments.manage');
|
||||||
$canViewPatients = $permissions->can($member, 'patients.view');
|
$canViewPatients = $permissions->can($member, 'patients.view');
|
||||||
@@ -1537,6 +1543,16 @@ class SpecialtyModuleController extends Controller
|
|||||||
? (string) (\App\Models\Branch::owned($owner)->whereKey($branchId)->value('name') ?? '')
|
? (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)
|
$done = Appointment::owned($owner)
|
||||||
->where('organization_id', $organization->id)
|
->where('organization_id', $organization->id)
|
||||||
->where('status', Appointment::STATUS_COMPLETED)
|
->where('status', Appointment::STATUS_COMPLETED)
|
||||||
@@ -2251,6 +2267,7 @@ class SpecialtyModuleController extends Controller
|
|||||||
'ambulanceHandover' => $ambulanceHandover,
|
'ambulanceHandover' => $ambulanceHandover,
|
||||||
'ambulanceStageCodes' => $ambulanceStageCodes,
|
'ambulanceStageCodes' => $ambulanceStageCodes,
|
||||||
'ambulanceStageFlow' => $ambulanceStageFlow,
|
'ambulanceStageFlow' => $ambulanceStageFlow,
|
||||||
|
'ambulanceDispatchBoard' => $ambulanceDispatchBoard ?? null,
|
||||||
'queueStubs' => $modules->queueStubsFor($organization, $module),
|
'queueStubs' => $modules->queueStubsFor($organization, $module),
|
||||||
'branchId' => $branchId,
|
'branchId' => $branchId,
|
||||||
'branchLabel' => $branchLabel,
|
'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
|
public function stageFlow(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
self::STAGE_CHECK_IN => ['next' => self::STAGE_DISPATCH, 'label' => 'Start dispatch'],
|
self::STAGE_CHECK_IN => ['next' => self::STAGE_DISPATCH, 'label' => 'Dispatch'],
|
||||||
self::STAGE_DISPATCH => ['next' => self::STAGE_ON_SCENE, 'label' => 'Move to on scene'],
|
self::STAGE_DISPATCH => ['next' => self::STAGE_ON_SCENE, 'label' => 'On scene'],
|
||||||
self::STAGE_ON_SCENE => ['next' => self::STAGE_TRANSPORT, 'label' => 'Move to transport'],
|
self::STAGE_ON_SCENE => ['next' => self::STAGE_TRANSPORT, 'label' => 'Transport'],
|
||||||
self::STAGE_TRANSPORT => ['next' => self::STAGE_HANDOVER, 'label' => 'Move to handover'],
|
self::STAGE_TRANSPORT => ['next' => self::STAGE_HANDOVER, 'label' => 'Handover'],
|
||||||
self::STAGE_HANDOVER => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'],
|
self::STAGE_HANDOVER => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete'],
|
||||||
self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'],
|
self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,9 +297,15 @@ class AppointmentService
|
|||||||
|
|
||||||
AuditLogger::record($ownerRef, 'appointment.walk_in', $organization->id, $actorRef, Appointment::class, $appointment->id);
|
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);
|
$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']);
|
return $appointment->fresh(['patient', 'practitioner', 'branch', 'visit']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -476,7 +476,7 @@ return [
|
|||||||
],
|
],
|
||||||
'ambulance' => [
|
'ambulance' => [
|
||||||
'label' => '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_type' => 'ambulance',
|
||||||
'department_name' => 'Ambulance',
|
'department_name' => 'Ambulance',
|
||||||
'queue_name' => 'Ambulance',
|
'queue_name' => 'Ambulance',
|
||||||
|
|||||||
@@ -877,8 +877,8 @@ return [
|
|||||||
],
|
],
|
||||||
'ambulance' => [
|
'ambulance' => [
|
||||||
'stages' => [
|
'stages' => [
|
||||||
['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'],
|
['code' => 'check_in', 'label' => 'New call', 'queue_point' => 'waiting'],
|
||||||
['code' => 'dispatch', 'label' => 'Dispatch', 'queue_point' => 'waiting'],
|
['code' => 'dispatch', 'label' => 'Dispatched', 'queue_point' => 'waiting'],
|
||||||
['code' => 'on_scene', 'label' => 'On scene', 'queue_point' => 'chair'],
|
['code' => 'on_scene', 'label' => 'On scene', 'queue_point' => 'chair'],
|
||||||
['code' => 'transport', 'label' => 'Transport', 'queue_point' => 'chair'],
|
['code' => 'transport', 'label' => 'Transport', 'queue_point' => 'chair'],
|
||||||
['code' => 'handover', 'label' => 'Handover', 'queue_point' => 'chair'],
|
['code' => 'handover', 'label' => 'Handover', 'queue_point' => 'chair'],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm shadow-slate-100/80">
|
||||||
|
<div class="border-b border-slate-100 bg-gradient-to-r from-amber-50/90 via-white to-slate-50 px-4 py-3 sm:px-5">
|
||||||
|
<p class="text-sm font-semibold text-slate-900">Dispatch board</p>
|
||||||
|
<p class="mt-0.5 text-sm text-slate-500">Track calls from intake through dispatch, scene, transport, and facility handover — not a desk call-next line.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex snap-x snap-mandatory gap-0 overflow-x-auto xl:grid xl:grid-cols-6 xl:overflow-visible" style="-ms-overflow-style:none;scrollbar-width:none;">
|
||||||
|
@foreach ($columns as $index => $column)
|
||||||
|
<section @class([
|
||||||
|
'flex w-[min(78vw,18rem)] shrink-0 snap-start flex-col border-slate-100 xl:w-auto',
|
||||||
|
'border-r' => $index < count($columns) - 1,
|
||||||
|
])>
|
||||||
|
<header class="sticky top-0 z-[1] flex items-center justify-between gap-2 border-b border-slate-100 bg-white/95 px-3 py-3 backdrop-blur-sm">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<span class="h-2 w-2 shrink-0 rounded-full {{ $column['dot'] }}"></span>
|
||||||
|
<h2 class="truncate text-xs font-semibold uppercase tracking-wide {{ $column['accent'] }}">{{ $column['label'] }}</h2>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-md bg-slate-100 px-2 py-0.5 text-xs font-semibold tabular-nums text-slate-700">{{ $column['count'] }}</span>
|
||||||
|
</header>
|
||||||
|
<div class="flex-1 space-y-2 p-3 sm:min-h-[12rem]">
|
||||||
|
@forelse ($column['items'] as $visit)
|
||||||
|
@include('care.specialty.ambulance.dispatch-card', [
|
||||||
|
'visit' => $visit,
|
||||||
|
'column' => $column,
|
||||||
|
'canAdvanceStage' => $canAdvanceStage && $column['key'] !== 'completed',
|
||||||
|
'specialtyStageRoute' => $specialtyStageRoute,
|
||||||
|
])
|
||||||
|
@empty
|
||||||
|
<p class="rounded-xl border border-dashed border-slate-200 px-3 py-8 text-center text-xs text-slate-400">{{ $column['empty'] }}</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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
|
||||||
|
<article
|
||||||
|
role="link"
|
||||||
|
tabindex="0"
|
||||||
|
data-href="{{ $openUrl }}"
|
||||||
|
onclick="if (! event.target.closest('a, button, form, input, select, textarea, label')) { window.location.href = this.dataset.href }"
|
||||||
|
onkeydown="if ((event.key === 'Enter' || event.key === ' ') && ! event.target.closest('a, button, form, input, select, textarea, label')) { event.preventDefault(); window.location.href = this.dataset.href }"
|
||||||
|
@class([
|
||||||
|
'group relative flex gap-3 rounded-xl border px-3 py-3 transition cursor-pointer hover:shadow-sm',
|
||||||
|
'border-amber-200 bg-amber-50/70' => ($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,
|
||||||
|
])
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1 space-y-1.5">
|
||||||
|
@if ($ticketNumber)
|
||||||
|
<span class="inline-flex rounded-md bg-slate-900 px-2 py-0.5 font-mono text-[11px] font-semibold tracking-wide text-white">{{ $ticketNumber }}</span>
|
||||||
|
@endif
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-semibold text-slate-900">{{ $patient?->fullName() ?? 'Patient' }}</p>
|
||||||
|
<p class="mt-0.5 truncate text-xs text-slate-500">
|
||||||
|
{{ $patient?->patient_number ?? '—' }}
|
||||||
|
@if ($reason)
|
||||||
|
<span class="text-slate-300"> · </span>{{ \Illuminate\Support\Str::limit($reason, 40) }}
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
@if ($appointment?->practitioner?->name)
|
||||||
|
<p class="mt-0.5 truncate text-xs text-slate-400">{{ $appointment->practitioner->name }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 flex shrink-0 flex-col items-end justify-center gap-1.5" @click.stop>
|
||||||
|
<a href="{{ $openUrl }}" class="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-[11px] font-medium text-slate-700 hover:bg-slate-50">Open</a>
|
||||||
|
@if ($canAdvanceStage && $nextStage && $nextLabel)
|
||||||
|
<form method="POST" action="{{ route($specialtyStageRoute, $visit) }}">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="stage" value="{{ $nextStage }}">
|
||||||
|
<button type="submit" class="rounded-lg bg-slate-900 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-slate-800">
|
||||||
|
{{ $nextLabel }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
$currentStage = $workspaceVisit?->specialty_stage;
|
$currentStage = $workspaceVisit?->specialty_stage;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if ($onWorkspace && $canCallNext)
|
@if ($onWorkspace && $canCallNext && ($moduleKey ?? '') !== 'ambulance')
|
||||||
@include('care.partials.queue-ops', [
|
@include('care.partials.queue-ops', [
|
||||||
'queueIntegration' => $queueIntegration ?? null,
|
'queueIntegration' => $queueIntegration ?? null,
|
||||||
'queueCallNextRoute' => 'care.specialty.call-next',
|
'queueCallNextRoute' => 'care.specialty.call-next',
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
'workspace' => ($patientHeader['patient'] ?? null)?->fullName() ?: 'Workspace',
|
'workspace' => ($patientHeader['patient'] ?? null)?->fullName() ?: 'Workspace',
|
||||||
'history' => 'Visit history',
|
'history' => 'Visit history',
|
||||||
'billing' => 'Billing',
|
'billing' => 'Billing',
|
||||||
'visits' => 'Patient queue',
|
'visits' => ($moduleKey ?? '') === 'ambulance' ? 'Dispatch board' : 'Patient queue',
|
||||||
default => 'Patient queue',
|
default => ($moduleKey ?? '') === 'ambulance' ? 'Dispatch board' : 'Patient queue',
|
||||||
};
|
};
|
||||||
@endphp
|
@endphp
|
||||||
<x-app-layout :title="$pageTitle">
|
<x-app-layout :title="$pageTitle">
|
||||||
@@ -197,66 +197,96 @@
|
|||||||
</div>
|
</div>
|
||||||
@include('care.specialty.sections.billing')
|
@include('care.specialty.sections.billing')
|
||||||
@else
|
@else
|
||||||
{{-- Queue home (overview / visits) — same UI as GP Queue --}}
|
{{-- Queue / dispatch home --}}
|
||||||
<div class="lg:grid lg:grid-cols-[minmax(0,1fr)_16rem] lg:items-start lg:gap-6">
|
<div class="lg:grid lg:grid-cols-[minmax(0,1fr)_16rem] lg:items-start lg:gap-6">
|
||||||
<div class="min-w-0 space-y-6">
|
<div class="min-w-0 space-y-6">
|
||||||
@php
|
@php
|
||||||
$specialtyLabel = $definition['nav_label'] ?? $definition['label'] ?? ucfirst($moduleKey);
|
$specialtyLabel = $definition['nav_label'] ?? $definition['label'] ?? ucfirst($moduleKey);
|
||||||
|
$isAmbulanceHome = $moduleKey === 'ambulance';
|
||||||
@endphp
|
@endphp
|
||||||
<div class="mb-1 flex items-center gap-2.5">
|
<div class="mb-1 flex items-center gap-2.5">
|
||||||
<x-care.specialty-icon :module="$moduleKey" boxed />
|
<x-care.specialty-icon :module="$moduleKey" boxed />
|
||||||
<p class="text-sm font-semibold text-slate-700">{{ $specialtyLabel }}</p>
|
<p class="text-sm font-semibold text-slate-700">{{ $specialtyLabel }}</p>
|
||||||
</div>
|
</div>
|
||||||
<x-care.page-hero
|
@if ($isAmbulanceHome)
|
||||||
badge="Patient flow · Call next"
|
<x-care.page-hero
|
||||||
:title="$specialtyLabel.' queue'"
|
badge="EMS · Dispatch"
|
||||||
description="Move patients from waiting through called, in care, and done."
|
:title="$specialtyLabel.' dispatch'"
|
||||||
:stats="[
|
description="Respond to call-ins — track New call through dispatch, scene, transport, and handover."
|
||||||
['value' => number_format($kpis['waiting'] ?? $queue->count()), 'label' => 'Waiting'],
|
:stats="[
|
||||||
['value' => number_format($kpis['in_progress'] ?? $inConsultation->count()), 'label' => 'In care'],
|
['value' => number_format($ambulanceDispatchBoard['active_count'] ?? 0), 'label' => 'Active calls'],
|
||||||
['value' => number_format($kpis['completed_today'] ?? 0), 'label' => 'Today'],
|
['value' => number_format($ambulanceDispatchBoard['completed_today'] ?? 0), 'label' => 'Completed today'],
|
||||||
]">
|
['value' => number_format($kpis['clinical_open'] ?? 0), 'label' => 'On scene open'],
|
||||||
@if ($canManageQueue ?? false)
|
]">
|
||||||
<x-slot name="actions">
|
@if ($canManageQueue ?? false)
|
||||||
<a href="{{ route('care.appointments.walk-in.create') }}" class="btn-primary">Walk-in</a>
|
<x-slot name="actions">
|
||||||
</x-slot>
|
<a href="{{ route('care.appointments.walk-in.create') }}" class="btn-primary">New call</a>
|
||||||
@endif
|
</x-slot>
|
||||||
</x-care.page-hero>
|
@endif
|
||||||
|
</x-care.page-hero>
|
||||||
|
@else
|
||||||
|
<x-care.page-hero
|
||||||
|
badge="Patient flow · Call next"
|
||||||
|
:title="$specialtyLabel.' queue'"
|
||||||
|
description="Move patients from waiting through called, in care, and done."
|
||||||
|
:stats="[
|
||||||
|
['value' => number_format($kpis['waiting'] ?? $queue->count()), 'label' => 'Waiting'],
|
||||||
|
['value' => number_format($kpis['in_progress'] ?? $inConsultation->count()), 'label' => 'In care'],
|
||||||
|
['value' => number_format($kpis['completed_today'] ?? 0), 'label' => 'Today'],
|
||||||
|
]">
|
||||||
|
@if ($canManageQueue ?? false)
|
||||||
|
<x-slot name="actions">
|
||||||
|
<a href="{{ route('care.appointments.walk-in.create') }}" class="btn-primary">Walk-in</a>
|
||||||
|
</x-slot>
|
||||||
|
@endif
|
||||||
|
</x-care.page-hero>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 text-sm">
|
<div class="flex flex-wrap gap-3 text-sm">
|
||||||
<a href="{{ route('care.specialty.history', $moduleKey) }}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-50">History</a>
|
<a href="{{ route('care.specialty.history', $moduleKey) }}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-50">History</a>
|
||||||
@if ($canManageClinical ?? $canManageSpecialty ?? true)
|
@if ($canManageClinical ?? $canManageSpecialty ?? true)
|
||||||
<a href="{{ route('care.specialty.billing', $moduleKey) }}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-50">Billing</a>
|
<a href="{{ route('care.specialty.billing', $moduleKey) }}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-50">Billing</a>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($isAmbulanceHome)
|
||||||
|
<a href="{{ route('care.specialty.ambulance.reports') }}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-50">Reports</a>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@include('care.partials.queue-board', [
|
@if ($isAmbulanceHome)
|
||||||
'queue' => $queue,
|
@include('care.specialty.ambulance.dispatch-board', [
|
||||||
'inConsultation' => $inConsultation,
|
'ambulanceDispatchBoard' => $ambulanceDispatchBoard ?? ['columns' => [], 'active_count' => 0, 'completed_today' => 0],
|
||||||
'done' => $done ?? collect(),
|
'canAdvanceStage' => $canAdvanceStage ?? false,
|
||||||
'queueIntegration' => $queueIntegration ?? null,
|
'specialtyStageRoute' => $specialtyStageRoute ?? 'care.specialty.ambulance.stage',
|
||||||
'branchId' => $branchId,
|
])
|
||||||
'branchLabel' => $branchLabel ?? null,
|
@else
|
||||||
'canConsult' => $canConsult ?? false,
|
@include('care.partials.queue-board', [
|
||||||
'lockToPractitioner' => $lockToPractitioner ?? false,
|
'queue' => $queue,
|
||||||
'showFilters' => true,
|
'inConsultation' => $inConsultation,
|
||||||
'queueCallNextRoute' => ($canCallNext ?? $canManageSpecialty ?? false) ? 'care.specialty.call-next' : null,
|
'done' => $done ?? collect(),
|
||||||
'queueCallNextParams' => ['module' => $moduleKey],
|
'queueIntegration' => $queueIntegration ?? null,
|
||||||
'startRouteName' => 'care.queue.start',
|
'branchId' => $branchId,
|
||||||
'inCareLabel' => 'In care',
|
'branchLabel' => $branchLabel ?? null,
|
||||||
'openInCareUrl' => function ($appointment) use ($moduleKey) {
|
'canConsult' => $canConsult ?? false,
|
||||||
if ($appointment->visit) {
|
'lockToPractitioner' => $lockToPractitioner ?? false,
|
||||||
return route('care.specialty.workspace', [
|
'showFilters' => true,
|
||||||
'module' => $moduleKey,
|
'queueCallNextRoute' => ($canCallNext ?? $canManageSpecialty ?? false) ? 'care.specialty.call-next' : null,
|
||||||
'visit' => $appointment->visit,
|
'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
|
return $appointment->consultation
|
||||||
? route('care.consultations.show', $appointment->consultation)
|
? route('care.consultations.show', $appointment->consultation)
|
||||||
: null;
|
: null;
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@include('care.partials.clinical-actions-panel', [
|
@include('care.partials.clinical-actions-panel', [
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class CareAmbulanceSuiteTest extends TestCase
|
|||||||
->assertSee('Ambulance overview')
|
->assertSee('Ambulance overview')
|
||||||
->assertSee('data-care-stage-bar', false)
|
->assertSee('data-care-stage-bar', false)
|
||||||
->assertSee('Scene / triage')
|
->assertSee('Scene / triage')
|
||||||
->assertSee('Start dispatch')
|
->assertSee('Dispatch')
|
||||||
->assertSee('Ambulance reports');
|
->assertSee('Ambulance reports');
|
||||||
|
|
||||||
$this->actingAs($this->owner)
|
$this->actingAs($this->owner)
|
||||||
@@ -240,4 +240,20 @@ class CareAmbulanceSuiteTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertDontSee('Ambulance overview');
|
->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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user