Restack queue and dispatch cards so actions never crowd the text.
Deploy Ladill Care / deploy (push) Successful in 1m21s

Board cards previously put Open/Start/advance buttons in a right-hand
column, which overlapped patient names in narrow board columns. Cards
now stack: badges on top, patient info in the middle, and a bordered
action footer. Also align two patient-queue tests with the clinical-
staff-only board access (receptionists are 403'd by design).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-20 12:42:22 +00:00
co-authored by Cursor
parent 527a469b78
commit 1bc0f84d9f
4 changed files with 76 additions and 53 deletions
@@ -6,6 +6,9 @@
Open (view workspace / form) is available whenever a destination URL exists Open (view workspace / form) is available whenever a destination URL exists
not gated by canConsult. Start / Call again remain consult-manage only. not gated by canConsult. Start / Call again remain consult-manage only.
Layout: stacked badges on top, patient info in the middle, actions in a
footer row so narrow board columns never squeeze text under buttons.
--}} --}}
@php @php
$stage = $stage ?? 'waiting'; $stage = $stage ?? 'waiting';
@@ -13,6 +16,10 @@
$isCalled = in_array($ticketStatus, ['called', 'serving'], true); $isCalled = in_array($ticketStatus, ['called', 'serving'], true);
$openUrl = isset($openInCareUrl) ? $openInCareUrl($appointment) : null; $openUrl = isset($openInCareUrl) ? $openInCareUrl($appointment) : null;
$canConsult = $canConsult ?? false; $canConsult = $canConsult ?? false;
$canRecall = ! empty($queueIntegration['enabled']) && $appointment->queue_ticket_uuid && $isCalled && $canConsult;
$showStart = $canConsult && ($stage === 'waiting' || $stage === 'called');
$hasActions = $openUrl || $canRecall || $showStart;
@endphp @endphp
<article <article
@if ($openUrl) @if ($openUrl)
@@ -23,15 +30,15 @@
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 }" 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 }"
@endif @endif
@class([ @class([
'group relative flex gap-3 rounded-xl border px-3 py-3 transition', 'group relative flex flex-col rounded-xl border transition',
'cursor-pointer hover:shadow-sm' => (bool) $openUrl, 'cursor-pointer hover:shadow-sm' => (bool) $openUrl,
'border-indigo-200 bg-indigo-50/70 ring-1 ring-indigo-100' => $stage === 'called' || ($stage === 'waiting' && $isCalled), 'border-indigo-200 bg-indigo-50/70 ring-1 ring-indigo-100' => $stage === 'called' || ($stage === 'waiting' && $isCalled),
'border-emerald-200 bg-emerald-50/60' => $stage === 'in_care', 'border-emerald-200 bg-emerald-50/60' => $stage === 'in_care',
'border-slate-100 bg-slate-50/80' => $stage === 'waiting' && ! $isCalled, 'border-slate-200 bg-white' => $stage === 'waiting' && ! $isCalled,
'border-slate-100 bg-white opacity-90' => $stage === 'done', 'border-slate-100 bg-white opacity-80' => $stage === 'done',
]) ])
> >
<div class="min-w-0 flex-1 space-y-1.5"> <div class="min-w-0 space-y-2 px-3 pt-3 {{ $hasActions ? 'pb-2.5' : 'pb-3' }}">
@if (! empty($queueIntegration['enabled']) && $appointment->queue_ticket_number) @if (! empty($queueIntegration['enabled']) && $appointment->queue_ticket_number)
@include('care.partials.queue-ticket', [ @include('care.partials.queue-ticket', [
'ticketNumber' => $appointment->queue_ticket_number, 'ticketNumber' => $appointment->queue_ticket_number,
@@ -39,12 +46,12 @@
'destination' => $appointment->queue_destination, 'destination' => $appointment->queue_destination,
'staffDisplayName' => $appointment->queue_staff_display_name, 'staffDisplayName' => $appointment->queue_staff_display_name,
'showAssignment' => $stage !== 'in_care' && $stage !== 'done', 'showAssignment' => $stage !== 'in_care' && $stage !== 'done',
'prominent' => true, 'prominent' => false,
]) ])
@elseif (! empty($queueIntegration['enabled']) && ($appointment->queue_routing_status ?? '') === 'unresolved') @elseif (! empty($queueIntegration['enabled']) && ($appointment->queue_routing_status ?? '') === 'unresolved')
<span class="inline-flex rounded-md bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800">Unassigned</span> <span class="inline-flex rounded-md bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800">Unassigned</span>
@elseif ($appointment->queue_position) @elseif ($appointment->queue_position)
<span class="inline-flex h-8 min-w-[2rem] items-center justify-center rounded-lg bg-slate-200/80 px-2 font-mono text-sm font-bold text-slate-700">{{ $appointment->queue_position }}</span> <span class="inline-flex h-6 min-w-[1.75rem] items-center justify-center rounded-md bg-slate-100 px-1.5 font-mono text-xs font-bold text-slate-600">{{ $appointment->queue_position }}</span>
@endif @endif
<div class="min-w-0"> <div class="min-w-0">
@@ -65,45 +72,35 @@
</div> </div>
</div> </div>
<div class="relative z-10 flex shrink-0 flex-col items-end justify-center gap-1.5" @click.stop> @if ($hasActions)
@if ($stage === 'waiting' || $stage === 'called') <div class="relative z-10 flex items-center justify-end gap-1.5 border-t border-slate-900/5 px-3 py-2" @click.stop>
@if (! empty($queueIntegration['enabled']) && $appointment->queue_ticket_uuid && $isCalled && $canConsult) @if ($canRecall)
<form method="POST" action="{{ route('care.queue.recall', $appointment) }}"> <form method="POST" action="{{ route('care.queue.recall', $appointment) }}">
@csrf @csrf
<button type="submit" class="rounded-lg border border-indigo-200 bg-white px-2.5 py-1 text-[11px] font-medium text-indigo-700 hover:bg-indigo-50">Call again</button> <button type="submit" class="rounded-lg px-2 py-1 text-xs font-medium text-indigo-700 hover:bg-indigo-100/60">Call again</button>
</form> </form>
@endif @endif
@if ($canConsult) @if ($openUrl)
<a href="{{ $openUrl }}" @class([
'rounded-lg px-2.5 py-1 text-xs font-semibold',
// Primary when it is the only meaningful action on the card.
'bg-emerald-600 text-white hover:bg-emerald-700' => $stage === 'in_care',
'bg-indigo-600 text-white hover:bg-indigo-700' => ! $showStart && $stage !== 'in_care' && $stage !== 'done',
'text-slate-600 hover:bg-slate-100' => $stage === 'done' || ($showStart && $stage !== 'in_care'),
])>Open</a>
@endif
@if ($showStart)
<form method="POST" action="{{ route($startRouteName, $appointment) }}"> <form method="POST" action="{{ route($startRouteName, $appointment) }}">
@csrf @csrf
<button type="submit" @class([ <button type="submit" @class([
'rounded-lg px-2.5 py-1.5 text-xs font-semibold text-white', 'rounded-lg px-2.5 py-1 text-xs font-semibold text-white',
'bg-indigo-600 hover:bg-indigo-700' => $isCalled, 'bg-indigo-600 hover:bg-indigo-700' => $isCalled,
'bg-slate-800 hover:bg-slate-900' => ! $isCalled, 'bg-slate-900 hover:bg-slate-800' => ! $isCalled,
])> ])>
{{ ! empty($queueIntegration['enabled']) && $ticketStatus === 'called' ? 'Serve & start' : 'Start' }} {{ ! empty($queueIntegration['enabled']) && $ticketStatus === 'called' ? 'Serve & start' : 'Start' }}
</button> </button>
</form> </form>
@endif @endif
@if ($openUrl) </div>
<a href="{{ $openUrl }}" @class([ @endif
'rounded-lg px-2.5 py-1.5 text-xs font-semibold',
'bg-indigo-600 text-white hover:bg-indigo-700' => ! $canConsult,
'border border-slate-200 bg-white text-slate-700 hover:bg-slate-50' => $canConsult,
])>Open</a>
@endif
@elseif ($stage === 'in_care')
@if (! empty($queueIntegration['enabled']) && $appointment->queue_ticket_uuid && $isCalled && $canConsult)
<form method="POST" action="{{ route('care.queue.recall', $appointment) }}">
@csrf
<button type="submit" class="rounded-lg border border-indigo-200 bg-white px-2.5 py-1 text-[11px] font-medium text-indigo-700 hover:bg-indigo-50">Call again</button>
</form>
@endif
@if ($openUrl)
<a href="{{ $openUrl }}" class="rounded-lg bg-emerald-600 px-2.5 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Open</a>
@endif
@elseif ($stage === 'done' && $openUrl)
<a href="{{ $openUrl }}" class="rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a>
@endif
</div>
</article> </article>
@@ -1,4 +1,8 @@
{{-- Single EMS call card on the ambulance dispatch board. --}} {{--
Single EMS call card on the ambulance dispatch board.
Stacked layout: ticket badge, patient info, then an action footer
narrow columns never push buttons over the text.
--}}
@php @php
$appointment = $visit->appointment; $appointment = $visit->appointment;
$patient = $visit->patient; $patient = $visit->patient;
@@ -8,6 +12,7 @@
$nextStage = $column['next_stage'] ?? null; $nextStage = $column['next_stage'] ?? null;
$nextLabel = $column['next_label'] ?? null; $nextLabel = $column['next_label'] ?? null;
$isCompleted = ($column['key'] ?? '') === 'completed'; $isCompleted = ($column['key'] ?? '') === 'completed';
$showAdvance = $canAdvanceStage && $nextStage && $nextLabel;
@endphp @endphp
<article <article
role="link" role="link"
@@ -16,25 +21,39 @@
onclick="if (! event.target.closest('a, button, form, input, select, textarea, label')) { window.location.href = this.dataset.href }" 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 }" 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([ @class([
'group relative flex gap-3 rounded-xl border px-3 py-3 transition cursor-pointer hover:shadow-sm', 'group relative flex cursor-pointer flex-col rounded-xl border bg-white transition hover:shadow-sm',
'border-amber-200 bg-amber-50/70' => ($column['key'] ?? '') === 'check_in', 'border-amber-200' => ($column['key'] ?? '') === 'check_in',
'border-indigo-200 bg-indigo-50/50' => ($column['key'] ?? '') === 'dispatch', 'border-indigo-200' => ($column['key'] ?? '') === 'dispatch',
'border-sky-200 bg-sky-50/50' => ($column['key'] ?? '') === 'on_scene', 'border-sky-200' => ($column['key'] ?? '') === 'on_scene',
'border-violet-200 bg-violet-50/50' => ($column['key'] ?? '') === 'transport', 'border-violet-200' => ($column['key'] ?? '') === 'transport',
'border-emerald-200 bg-emerald-50/50' => ($column['key'] ?? '') === 'handover', 'border-emerald-200' => ($column['key'] ?? '') === 'handover',
'border-slate-100 bg-white opacity-90' => $isCompleted, 'border-slate-100 opacity-80' => $isCompleted,
]) ])
> >
<div class="min-w-0 flex-1 space-y-1.5"> <div class="min-w-0 space-y-2 px-3 pb-2.5 pt-3">
@if ($ticketNumber) <div class="flex items-center justify-between gap-2">
<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> @if ($ticketNumber)
@endif <span class="inline-flex rounded-md bg-slate-100 px-2 py-0.5 font-mono text-[11px] font-bold tracking-wide text-slate-700">{{ $ticketNumber }}</span>
@else
<span></span>
@endif
<span @class([
'h-2 w-2 shrink-0 rounded-full',
'bg-amber-400' => ($column['key'] ?? '') === 'check_in',
'bg-indigo-400' => ($column['key'] ?? '') === 'dispatch',
'bg-sky-400' => ($column['key'] ?? '') === 'on_scene',
'bg-violet-400' => ($column['key'] ?? '') === 'transport',
'bg-emerald-400' => ($column['key'] ?? '') === 'handover',
'bg-slate-300' => $isCompleted,
])></span>
</div>
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm font-semibold text-slate-900">{{ $patient?->fullName() ?? 'Patient' }}</p> <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"> <p class="mt-0.5 truncate text-xs text-slate-500">
{{ $patient?->patient_number ?? '—' }} {{ $patient?->patient_number ?? '—' }}
@if ($reason) @if ($reason)
<span class="text-slate-300"> · </span>{{ \Illuminate\Support\Str::limit($reason, 40) }} <span class="text-slate-300"> · </span>{{ \Illuminate\Support\Str::limit($reason, 48) }}
@endif @endif
</p> </p>
@if ($appointment?->practitioner?->name) @if ($appointment?->practitioner?->name)
@@ -43,14 +62,14 @@
</div> </div>
</div> </div>
<div class="relative z-10 flex shrink-0 flex-col items-end justify-center gap-1.5" @click.stop> <div class="relative z-10 flex items-center justify-end gap-1.5 border-t border-slate-900/5 px-3 py-2" @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> <a href="{{ $openUrl }}" class="rounded-lg px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-100">Open</a>
@if ($canAdvanceStage && $nextStage && $nextLabel) @if ($showAdvance)
<form method="POST" action="{{ route($specialtyStageRoute, $visit) }}"> <form method="POST" action="{{ route($specialtyStageRoute, $visit) }}">
@csrf @csrf
<input type="hidden" name="stage" value="{{ $nextStage }}"> <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"> <button type="submit" class="rounded-lg bg-slate-900 px-2.5 py-1 text-xs font-semibold text-white hover:bg-slate-800">
{{ $nextLabel }} {{ $nextLabel }}
</button> </button>
</form> </form>
@endif @endif
@@ -59,11 +59,12 @@ class CareQueueBoardPerformanceTest extends TestCase
], ],
]); ]);
// Patient queue board is reserved for clinical staff (receptionists are 403'd).
Member::create([ Member::create([
'owner_ref' => $this->owner->public_id, 'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
'user_ref' => $this->owner->public_id, 'user_ref' => $this->owner->public_id,
'role' => 'receptionist', 'role' => 'doctor',
]); ]);
$this->branch = Branch::create([ $this->branch = Branch::create([
+6
View File
@@ -316,6 +316,12 @@ class CareQueueWorkflowTest extends TestCase
{ {
Http::fake(); Http::fake();
// Patient queue is reserved for clinical staff; receptionists are 403'd.
Member::query()->where('user_ref', $this->owner->public_id)->update([
'role' => 'doctor',
'branch_id' => $this->branch->id,
]);
$this->actingAs($this->owner) $this->actingAs($this->owner)
->get(route('care.queue.index')) ->get(route('care.queue.index'))
->assertOk() ->assertOk()