Gate specialty mutate CTAs on Care abilities, not only module manage.
Deploy Ladill Care / deploy (push) Successful in 1m3s

Nurses can manage limited specialties but lack consultations.manage, so
hide Start/stage/chart edits that 403 while keeping queue/vitals/billing
aligned with what those routes actually authorize.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 18:45:00 +00:00
co-authored by Cursor
parent 9e32eb32c5
commit 8a5330d3d2
20 changed files with 312 additions and 38 deletions
@@ -77,7 +77,13 @@ class EmergencyWorkspaceController extends Controller
SpecialtyModuleService $modules, SpecialtyModuleService $modules,
EmergencyVitalsService $vitals, EmergencyVitalsService $vitals,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $permissions = app(\App\Services\Care\CarePermissions::class);
$member = $this->member($request);
abort_unless(
$permissions->can($member, 'vitals.manage')
|| $permissions->can($member, 'consultations.manage'),
403,
);
$this->assertEmergencyManage($request, $modules); $this->assertEmergencyManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
@@ -637,8 +637,20 @@ class SpecialtyModuleController extends Controller
$services = $shell->provisionedServices($organization, $module); $services = $shell->provisionedServices($organization, $module);
$permissions = app(\App\Services\Care\CarePermissions::class); $permissions = app(\App\Services\Care\CarePermissions::class);
// Match every ability that mutate routes actually check — module canManage alone is not enough.
$canConsult = $permissions->can($member, 'consultations.manage') && $canManageSpecialty; $canConsult = $permissions->can($member, 'consultations.manage') && $canManageSpecialty;
$canAdvanceStage = $canConsult;
$canStartConsultation = $canConsult;
$canCallNext = $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;
$canBookAppointments = $permissions->can($member, 'appointments.manage');
$canViewPatients = $permissions->can($member, 'patients.view');
$canViewAppointments = $permissions->can($member, 'appointments.view');
$branchLabel = $branchId $branchLabel = $branchId
? (string) (\App\Models\Branch::owned($owner)->whereKey($branchId)->value('name') ?? '') ? (string) (\App\Models\Branch::owned($owner)->whereKey($branchId)->value('name') ?? '')
: ''; : '';
@@ -880,7 +892,15 @@ class SpecialtyModuleController extends Controller
'practitionerScope' => $practitionerScope, 'practitionerScope' => $practitionerScope,
'lockToPractitioner' => $practitionerScope !== null, 'lockToPractitioner' => $practitionerScope !== null,
'canConsult' => $canConsult, 'canConsult' => $canConsult,
'canAdvanceStage' => $canAdvanceStage,
'canStartConsultation' => $canStartConsultation,
'canCallNext' => $canCallNext,
'canManageClinical' => $canManageClinical,
'canManageVitals' => $canManageVitals,
'canManageQueue' => $canManageQueue, 'canManageQueue' => $canManageQueue,
'canBookAppointments' => $canBookAppointments,
'canViewPatients' => $canViewPatients,
'canViewAppointments' => $canViewAppointments,
'canManageSpecialty' => $canManageSpecialty, 'canManageSpecialty' => $canManageSpecialty,
'canViewSpecialty' => $canViewSpecialty, 'canViewSpecialty' => $canViewSpecialty,
'canReferSpecialty' => $canReferSpecialty, 'canReferSpecialty' => $canReferSpecialty,
@@ -1,4 +1,4 @@
@php $canManage = $canManageSpecialty ?? true; @endphp @php $canManage = $canConsult ?? false; @endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5"> <section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Dental imaging</h3> <h3 class="text-sm font-semibold text-slate-900">Dental imaging</h3>
@@ -1,4 +1,4 @@
@php $canManage = $canManageSpecialty ?? true; @endphp @php $canManage = $canConsult ?? false; @endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5"> <section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Lab cases</h3> <h3 class="text-sm font-semibold text-slate-900">Lab cases</h3>
@@ -1,4 +1,4 @@
@php $canManage = $canManageSpecialty ?? true; @endphp @php $canManage = $canConsult ?? false; @endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5"> <section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Dental visit notes</h3> <h3 class="text-sm font-semibold text-slate-900">Dental visit notes</h3>
@@ -3,7 +3,7 @@
$selected = old('tooth_code', request('tooth', ($dentalRows['upper'][0] ?? '16'))); $selected = old('tooth_code', request('tooth', ($dentalRows['upper'][0] ?? '16')));
$selectedData = $teethMap[$selected] ?? ['status' => 'present', 'surfaces' => [], 'conditions' => [], 'notes' => null]; $selectedData = $teethMap[$selected] ?? ['status' => 'present', 'surfaces' => [], 'conditions' => [], 'notes' => null];
$dentition = $dentalChart->dentition ?? 'adult'; $dentition = $dentalChart->dentition ?? 'adult';
$canManage = $canManageSpecialty ?? true; $canManage = $canConsult ?? false;
@endphp @endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5"> <section class="rounded-2xl border border-slate-200 bg-white p-5">
@@ -2,7 +2,7 @@
$plan = $dentalPlan; $plan = $dentalPlan;
$openItems = $plan?->items?->whereIn('status', ['proposed', 'accepted']) ?? collect(); $openItems = $plan?->items?->whereIn('status', ['proposed', 'accepted']) ?? collect();
$currentStage = $workspaceVisit->specialty_stage; $currentStage = $workspaceVisit->specialty_stage;
$canManage = $canManageSpecialty ?? true; $canManage = $canConsult ?? false;
$stageFlow = [ $stageFlow = [
'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'],
'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'],
@@ -6,7 +6,7 @@
} }
$teeth = array_merge($dentalRows['upper'] ?? [], $dentalRows['lower'] ?? []); $teeth = array_merge($dentalRows['upper'] ?? [], $dentalRows['lower'] ?? []);
$probeTeeth = array_slice($teeth, 0, 8); $probeTeeth = array_slice($teeth, 0, 8);
$canManage = $canManageSpecialty ?? true; $canManage = $canConsult ?? false;
@endphp @endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5"> <section class="rounded-2xl border border-slate-200 bg-white p-5">
@@ -1,7 +1,7 @@
@php @php
$plan = $dentalPlan; $plan = $dentalPlan;
$history = ($dentalPlanHistory ?? collect())->filter(fn ($p) => ! $plan || $p->id !== $plan->id); $history = ($dentalPlanHistory ?? collect())->filter(fn ($p) => ! $plan || $p->id !== $plan->id);
$canManage = $canManageSpecialty ?? true; $canManage = $canConsult ?? false;
@endphp @endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5"> <section class="rounded-2xl border border-slate-200 bg-white p-5">
@@ -1,4 +1,4 @@
@php $canManage = $canManageSpecialty ?? true; @endphp @php $canManage = $canConsult ?? false; @endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5"> <section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Recalls</h3> <h3 class="text-sm font-semibold text-slate-900">Recalls</h3>
@@ -1,4 +1,4 @@
@php $canManage = $canManageSpecialty ?? true; @endphp @php $canManage = $canConsult ?? false; @endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5"> <section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Treat today</h3> <h3 class="text-sm font-semibold text-slate-900">Treat today</h3>
@@ -1,7 +1,7 @@
@php @php
$record = $emergencyDisposition; $record = $emergencyDisposition;
$payload = old('payload', $record?->payload ?? []); $payload = old('payload', $record?->payload ?? []);
$canManage = $canManageSpecialty ?? true; $canManage = $canConsult ?? false;
@endphp @endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5"> <section class="rounded-2xl border border-slate-200 bg-white p-5">
@@ -4,7 +4,7 @@
$currentStage = $workspaceVisit->specialty_stage; $currentStage = $workspaceVisit->specialty_stage;
$stageFlow = $emergencyStageFlow ?? []; $stageFlow = $emergencyStageFlow ?? [];
$latest = $emergencyLatestVitals; $latest = $emergencyLatestVitals;
$canManage = $canManageSpecialty ?? true; $canManage = $canConsult ?? false;
@endphp @endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5"> <section class="rounded-2xl border border-slate-200 bg-white p-5">
@@ -1,4 +1,4 @@
@php $canManage = $canManageSpecialty ?? true; @endphp @php $canManage = $canManageVitals ?? false; @endphp
<section class="rounded-2xl border border-slate-200 bg-white p-5"> <section class="rounded-2xl border border-slate-200 bg-white p-5">
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
@@ -8,7 +8,14 @@
$appointment = $workspaceVisit?->appointment; $appointment = $workspaceVisit?->appointment;
$openConsultation = $appointment?->consultation; $openConsultation = $appointment?->consultation;
$ticketCalled = $appointment && $appointment->queue_ticket_status === 'called'; $ticketCalled = $appointment && $appointment->queue_ticket_status === 'called';
$canStartConsultation = $appointment && in_array($appointment->status, [ $canCallNext = $canCallNext ?? ($canManageSpecialty ?? false);
$canConsult = $canConsult ?? false;
$canAdvanceStage = $canAdvanceStage ?? $canConsult;
$canStartConsultationFlag = $canStartConsultation ?? $canConsult;
$canBookAppointments = $canBookAppointments ?? false;
$canViewPatients = $canViewPatients ?? true;
$canViewAppointments = $canViewAppointments ?? true;
$appointmentReadyToStart = $appointment && in_array($appointment->status, [
\App\Models\Appointment::STATUS_WAITING, \App\Models\Appointment::STATUS_WAITING,
\App\Models\Appointment::STATUS_CHECKED_IN, \App\Models\Appointment::STATUS_CHECKED_IN,
], true); ], true);
@@ -17,7 +24,8 @@
$appointment->status === \App\Models\Appointment::STATUS_IN_CONSULTATION $appointment->status === \App\Models\Appointment::STATUS_IN_CONSULTATION
|| ($openConsultation && $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED) || ($openConsultation && $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED)
); );
$canCompleteConsultation = $consultationInProgress $canCompleteConsultation = $canConsult
&& $consultationInProgress
&& $openConsultation && $openConsultation
&& $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED; && $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED;
$chartTab = collect($workspaceTabs ?? []) $chartTab = collect($workspaceTabs ?? [])
@@ -40,7 +48,7 @@
$currentStage = $workspaceVisit?->specialty_stage; $currentStage = $workspaceVisit?->specialty_stage;
@endphp @endphp
@if ($onWorkspace && ($canManageSpecialty ?? true)) @if ($onWorkspace && $canCallNext)
@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',
@@ -50,7 +58,7 @@
]) ])
@endif @endif
@if (($canManageSpecialty ?? true) && $ticketCalled && $appointment) @if ($canConsult && $ticketCalled && $appointment)
<form method="POST" action="{{ route('care.queue.recall', $appointment) }}" class="w-full" @click.stop> <form method="POST" action="{{ route('care.queue.recall', $appointment) }}" class="w-full" @click.stop>
@csrf @csrf
<button type="submit" class="{{ $btnAccent }}">Call again</button> <button type="submit" class="{{ $btnAccent }}">Call again</button>
@@ -58,25 +66,25 @@
@endif @endif
@if ($workspaceVisit) @if ($workspaceVisit)
@if (($canManageSpecialty ?? true) && $moduleKey === 'dentistry' && $currentStage && isset($stageFlow[$currentStage])) @if ($canAdvanceStage && $moduleKey === 'dentistry' && $currentStage && isset($stageFlow[$currentStage]))
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}" class="w-full" @click.stop> <form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}" class="w-full" @click.stop>
@csrf @csrf
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}"> <input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
<button type="submit" class="{{ $btnAccent }}">{{ $stageFlow[$currentStage]['label'] }}</button> <button type="submit" class="{{ $btnAccent }}">{{ $stageFlow[$currentStage]['label'] }}</button>
</form> </form>
@elseif (($canManageSpecialty ?? true) && $moduleKey === 'dentistry' && ! $currentStage) @elseif ($canAdvanceStage && $moduleKey === 'dentistry' && ! $currentStage)
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}" class="w-full" @click.stop> <form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}" class="w-full" @click.stop>
@csrf @csrf
<input type="hidden" name="stage" value="chair"> <input type="hidden" name="stage" value="chair">
<button type="submit" class="{{ $btnAccent }}">Seat at chair</button> <button type="submit" class="{{ $btnAccent }}">Seat at chair</button>
</form> </form>
@elseif (($canManageSpecialty ?? true) && $moduleKey === 'emergency' && $currentStage && isset($stageFlow[$currentStage]) && ($stageFlow[$currentStage]['next'] ?? null) !== $currentStage) @elseif ($canAdvanceStage && $moduleKey === 'emergency' && $currentStage && isset($stageFlow[$currentStage]) && ($stageFlow[$currentStage]['next'] ?? null) !== $currentStage)
<form method="POST" action="{{ route('care.specialty.emergency.stage', $workspaceVisit) }}" class="w-full" @click.stop> <form method="POST" action="{{ route('care.specialty.emergency.stage', $workspaceVisit) }}" class="w-full" @click.stop>
@csrf @csrf
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}"> <input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
<button type="submit" class="{{ $btnAccent }}">{{ $stageFlow[$currentStage]['label'] }}</button> <button type="submit" class="{{ $btnAccent }}">{{ $stageFlow[$currentStage]['label'] }}</button>
</form> </form>
@elseif (($canManageSpecialty ?? true) && $moduleKey === 'emergency' && ! $currentStage) @elseif ($canAdvanceStage && $moduleKey === 'emergency' && ! $currentStage)
<form method="POST" action="{{ route('care.specialty.emergency.stage', $workspaceVisit) }}" class="w-full" @click.stop> <form method="POST" action="{{ route('care.specialty.emergency.stage', $workspaceVisit) }}" class="w-full" @click.stop>
@csrf @csrf
<input type="hidden" name="stage" value="arrival"> <input type="hidden" name="stage" value="arrival">
@@ -84,7 +92,7 @@
</form> </form>
@endif @endif
@if (($canManageSpecialty ?? true) && $canCompleteConsultation) @if ($canCompleteConsultation)
<button <button
type="button" type="button"
class="{{ $btnPrimary }}" class="{{ $btnPrimary }}"
@@ -99,7 +107,7 @@
class="{{ $btnSecondary }}"> class="{{ $btnSecondary }}">
Open chart Open chart
</a> </a>
@elseif (($canManageSpecialty ?? true) && $canStartConsultation) @elseif ($canStartConsultationFlag && $appointmentReadyToStart)
<form method="POST" action="{{ route('care.specialty.consultation.start', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" class="w-full" @click.stop> <form method="POST" action="{{ route('care.specialty.consultation.start', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" class="w-full" @click.stop>
@csrf @csrf
<button type="submit" class="{{ $btnPrimary }}"> <button type="submit" class="{{ $btnPrimary }}">
@@ -116,12 +124,12 @@
<button type="submit" class="{{ $btnAccent }}">Refer to specialty queue</button> <button type="submit" class="{{ $btnAccent }}">Refer to specialty queue</button>
</form> </form>
@endif @endif
@if ($workspaceVisit->patient) @if ($canViewPatients && $workspaceVisit->patient)
<a href="{{ route('care.patients.show', $workspaceVisit->patient) }}" class="{{ $btnSecondary }}"> <a href="{{ route('care.patients.show', $workspaceVisit->patient) }}" class="{{ $btnSecondary }}">
Patient chart Patient chart
</a> </a>
@endif @endif
@if ($workspaceVisit->appointment) @if ($canViewAppointments && $workspaceVisit->appointment)
<a href="{{ route('care.appointments.show', $workspaceVisit->appointment) }}" class="{{ $btnSecondary }}"> <a href="{{ route('care.appointments.show', $workspaceVisit->appointment) }}" class="{{ $btnSecondary }}">
Appointment Appointment
</a> </a>
@@ -132,6 +140,8 @@
Back to queue Back to queue
</a> </a>
@if ($canBookAppointments)
<a href="{{ route('care.appointments.create') }}" class="{{ $btnAccent }}"> <a href="{{ route('care.appointments.create') }}" class="{{ $btnAccent }}">
Register / book Register / book
</a> </a>
@endif
@@ -29,7 +29,7 @@
</div> </div>
@endif @endif
@if (! ($canManageSpecialty ?? true)) @if (! ($canManageClinical ?? $canManageSpecialty ?? true))
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2"> <dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
@forelse ($clinicalFields as $field) @forelse ($clinicalFields as $field)
@php @php
@@ -43,9 +43,9 @@
@elseif ($activeTab === 'billing') @elseif ($activeTab === 'billing')
<section class="rounded-2xl border border-slate-200 bg-white p-5"> <section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900"> <h3 class="text-sm font-semibold text-slate-900">
{{ ($canManageSpecialty ?? true) ? 'Add specialty service' : 'Visit billing' }} {{ ($canManageClinical ?? $canManageSpecialty ?? true) ? 'Add specialty service' : 'Visit billing' }}
</h3> </h3>
@if ($canManageSpecialty ?? true) @if ($canManageClinical ?? $canManageSpecialty ?? true)
<form method="POST" action="{{ route('care.specialty.services.add', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" class="mt-3 space-y-3"> <form method="POST" action="{{ route('care.specialty.services.add', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" class="mt-3 space-y-3">
@csrf @csrf
<select name="service_code" required class="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm"> <select name="service_code" required class="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
@@ -61,7 +61,7 @@
@endif @endif
@if ($workspaceVisit->bill) @if ($workspaceVisit->bill)
<div @class(['mt-6 border-t border-slate-100 pt-4' => ($canManageSpecialty ?? true), 'mt-3' => ! ($canManageSpecialty ?? true)])> <div @class(['mt-6 border-t border-slate-100 pt-4' => ($canManageClinical ?? $canManageSpecialty ?? true), 'mt-3' => ! ($canManageClinical ?? $canManageSpecialty ?? true)])>
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Current invoice {{ $workspaceVisit->bill->invoice_number }}</p> <p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Current invoice {{ $workspaceVisit->bill->invoice_number }}</p>
<ul class="mt-2 space-y-1 text-sm"> <ul class="mt-2 space-y-1 text-sm">
@forelse ($workspaceVisit->bill->lineItems as $line) @forelse ($workspaceVisit->bill->lineItems as $line)
@@ -75,7 +75,7 @@
</ul> </ul>
<a href="{{ route('care.bills.show', $workspaceVisit->bill) }}" class="mt-3 inline-flex text-sm font-medium text-indigo-600">Open bill</a> <a href="{{ route('care.bills.show', $workspaceVisit->bill) }}" class="mt-3 inline-flex text-sm font-medium text-indigo-600">Open bill</a>
</div> </div>
@elseif (! ($canManageSpecialty ?? true)) @elseif (! ($canManageClinical ?? $canManageSpecialty ?? true))
<p class="mt-3 text-sm text-slate-500">No invoice on this visit yet.</p> <p class="mt-3 text-sm text-slate-500">No invoice on this visit yet.</p>
@endif @endif
</section> </section>
@@ -88,7 +88,7 @@
<h3 class="text-sm font-semibold text-slate-900">Documents</h3> <h3 class="text-sm font-semibold text-slate-900">Documents</h3>
<p class="mt-1 text-xs text-slate-500">Specialty documents attach to the patient chart for this episode.</p> <p class="mt-1 text-xs text-slate-500">Specialty documents attach to the patient chart for this episode.</p>
@if ($canManageSpecialty ?? true) @if ($canManageClinical ?? $canManageSpecialty ?? true)
<form method="POST" action="{{ route('care.specialty.documents.upload', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" enctype="multipart/form-data" class="mt-4 space-y-3"> <form method="POST" action="{{ route('care.specialty.documents.upload', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" enctype="multipart/form-data" class="mt-4 space-y-3">
@csrf @csrf
<input type="file" name="attachment" required accept=".pdf,.jpg,.jpeg,.png,.webp" class="block w-full text-sm"> <input type="file" name="attachment" required accept=".pdf,.jpg,.jpeg,.png,.webp" class="block w-full text-sm">
+10 -4
View File
@@ -20,7 +20,7 @@
<a href="{{ route('care.specialty.emergency.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a> <a href="{{ route('care.specialty.emergency.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
@endif @endif
<a href="{{ route('care.specialty.history', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">History</a> <a href="{{ route('care.specialty.history', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">History</a>
@if ($canManageSpecialty ?? true) @if ($canManageClinical ?? $canManageSpecialty ?? true)
<a href="{{ route('care.specialty.billing', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">Billing</a> <a href="{{ route('care.specialty.billing', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">Billing</a>
@endif @endif
</div> </div>
@@ -34,6 +34,10 @@
You have <strong>view-only</strong> access. Results and history are available; clinical edits require specialty access. You have <strong>view-only</strong> access. Results and history are available; clinical edits require specialty access.
@endif @endif
</div> </div>
@elseif (($canManageSpecialty ?? false) && ! ($canConsult ?? false))
<div class="rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-950">
You can work this specialty floor (queue, vitals, billing support) but starting encounters and clinical chart edits require a clinician with consultation access.
</div>
@endif @endif
<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">
@@ -51,7 +55,9 @@
]) ])
@php @php
$completeConsultation = $workspaceVisit?->appointment?->consultation; $completeConsultation = ($canConsult ?? false)
? $workspaceVisit?->appointment?->consultation
: null;
@endphp @endphp
@if ($completeConsultation && $completeConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED) @if ($completeConsultation && $completeConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED)
<x-confirm-dialog <x-confirm-dialog
@@ -116,7 +122,7 @@
<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 ($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
</div> </div>
@@ -130,7 +136,7 @@
'canConsult' => $canConsult ?? false, 'canConsult' => $canConsult ?? false,
'lockToPractitioner' => $lockToPractitioner ?? false, 'lockToPractitioner' => $lockToPractitioner ?? false,
'showFilters' => true, 'showFilters' => true,
'queueCallNextRoute' => ($canManageSpecialty ?? true) ? 'care.specialty.call-next' : null, 'queueCallNextRoute' => ($canCallNext ?? $canManageSpecialty ?? false) ? 'care.specialty.call-next' : null,
'queueCallNextParams' => ['module' => $moduleKey], 'queueCallNextParams' => ['module' => $moduleKey],
'startRouteName' => 'care.queue.start', 'startRouteName' => 'care.queue.start',
'inCareLabel' => 'In care', 'inCareLabel' => 'In care',
+228
View File
@@ -340,4 +340,232 @@ class CareSpecialtyAccessTest extends TestCase
$this->assertTrue($this->modules->memberCanManage($this->organization, $admin, 'cardiology')); $this->assertTrue($this->modules->memberCanManage($this->organization, $admin, 'cardiology'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $admin, 'emergency')); $this->assertTrue($this->modules->memberCanManage($this->organization, $admin, 'emergency'));
} }
/**
* @return array{0: \App\Models\User, 1: Member, 2: \App\Models\Visit}
*/
protected function dentistryVisitForRole(string $role, string $suffix): array
{
[$user, $member] = $this->makeStaff($role, $suffix);
if ($role === 'doctor') {
Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'member_id' => $member->id,
'user_ref' => $user->public_id,
'name' => 'Dr '.$suffix,
'specialty' => 'Dentistry',
'is_active' => true,
]);
}
$dept = Department::query()->where('type', 'dental')->firstOrFail();
$patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-DEN-'.$suffix,
'first_name' => 'Efua',
'last_name' => 'Boateng',
'gender' => 'female',
'date_of_birth' => '1992-06-01',
]);
$visit = \App\Models\Visit::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'status' => \App\Models\Visit::STATUS_IN_PROGRESS,
'checked_in_at' => now(),
'specialty_stage' => 'waiting',
]);
Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'department_id' => $dept->id,
'visit_id' => $visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'scheduled_at' => now(),
'checked_in_at' => now(),
'waiting_at' => now(),
]);
return [$user, $member, $visit];
}
public function test_nurse_on_dentistry_does_not_see_consultation_mutate_actions(): void
{
[$nurseUser, $nurseMember, $visit] = $this->dentistryVisitForRole('nurse', 'den-nurse');
$this->assertTrue($this->modules->memberCanManage($this->organization, $nurseMember, 'dentistry'));
$this->assertFalse(app(\App\Services\Care\CarePermissions::class)->can($nurseMember, 'consultations.manage'));
$html = $this->actingAs($nurseUser)
->get(route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'overview']))
->assertOk()
->assertSee('Efua Boateng')
->assertSee('consultation access')
->assertDontSee('Seat at chair')
->assertDontSee('Register / book')
->assertSee('Back to queue')
->assertSee('Patient chart')
->getContent();
$this->assertStringContainsString('Waiting', $html);
$this->assertStringNotContainsString(
route('care.specialty.dentistry.stage', $visit, absolute: false),
$html,
);
$this->assertStringNotContainsString(
route('care.specialty.consultation.start', ['module' => 'dentistry', 'visit' => $visit], absolute: false),
$html,
);
}
public function test_nurse_dentistry_mutate_posts_are_forbidden(): void
{
[$nurseUser, , $visit] = $this->dentistryVisitForRole('nurse', 'den-nurse-post');
$this->actingAs($nurseUser)
->post(route('care.specialty.dentistry.stage', $visit), ['stage' => 'chair'])
->assertForbidden();
$this->actingAs($nurseUser)
->post(route('care.specialty.consultation.start', ['module' => 'dentistry', 'visit' => $visit]))
->assertForbidden();
$this->actingAs($nurseUser)
->post(route('care.specialty.dentistry.tooth', $visit), [
'tooth_code' => '16',
'status' => 'present',
])
->assertForbidden();
}
public function test_doctor_on_dentistry_sees_and_can_advance_stage(): void
{
[$doctorUser, $doctorMember, $visit] = $this->dentistryVisitForRole('doctor', 'den-doc');
$this->assertTrue($this->modules->memberCanManage($this->organization, $doctorMember, 'dentistry'));
$this->assertTrue(app(\App\Services\Care\CarePermissions::class)->can($doctorMember, 'consultations.manage'));
$this->actingAs($doctorUser)
->get(route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'overview']))
->assertOk()
->assertSee('Seat at chair')
->assertSee('Start')
->assertDontSee('consultation access');
$this->actingAs($doctorUser)
->post(route('care.specialty.dentistry.stage', $visit), ['stage' => 'chair'])
->assertRedirect();
$this->assertSame('chair', $visit->fresh()->specialty_stage);
}
public function test_admin_on_dentistry_sees_mutate_actions(): void
{
$dept = Department::query()->where('type', 'dental')->firstOrFail();
$patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-DEN-ADMIN',
'first_name' => 'Admin',
'last_name' => 'Patient',
'gender' => 'male',
'date_of_birth' => '1980-01-01',
]);
$visit = \App\Models\Visit::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'status' => \App\Models\Visit::STATUS_IN_PROGRESS,
'checked_in_at' => now(),
'specialty_stage' => 'waiting',
]);
Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'department_id' => $dept->id,
'visit_id' => $visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'scheduled_at' => now(),
'checked_in_at' => now(),
'waiting_at' => now(),
]);
$this->actingAs($this->owner)
->get(route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'overview']))
->assertOk()
->assertSee('Seat at chair')
->assertSee('Start');
}
public function test_nurse_can_save_emergency_vitals_with_vitals_manage(): void
{
[$nurseUser, $nurseMember] = $this->makeStaff('nurse', 'er-vitals');
$this->assertTrue(app(\App\Services\Care\CarePermissions::class)->can($nurseMember, 'vitals.manage'));
$this->assertFalse(app(\App\Services\Care\CarePermissions::class)->can($nurseMember, 'consultations.manage'));
$dept = Department::query()->where('type', 'emergency')->firstOrFail();
$patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-ER-VITALS',
'first_name' => 'Kojo',
'last_name' => 'Mensah',
'gender' => 'male',
'date_of_birth' => '1988-01-01',
]);
$visit = \App\Models\Visit::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'status' => \App\Models\Visit::STATUS_IN_PROGRESS,
'checked_in_at' => now(),
]);
Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'department_id' => $dept->id,
'visit_id' => $visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_IN_CONSULTATION,
'scheduled_at' => now(),
'checked_in_at' => now(),
'started_at' => now(),
]);
$this->actingAs($nurseUser)
->get(route('care.specialty.workspace', ['module' => 'emergency', 'visit' => $visit, 'tab' => 'vitals']))
->assertOk()
->assertSee('Save vitals')
->assertDontSee('Seat at chair')
->assertDontSee('Start triage');
$this->actingAs($nurseUser)
->post(route('care.specialty.emergency.vitals', $visit), [
'bp_systolic' => 120,
'bp_diastolic' => 80,
'pulse' => 72,
])
->assertRedirect();
$this->actingAs($nurseUser)
->post(route('care.specialty.emergency.stage', $visit), ['stage' => 'treatment'])
->assertForbidden();
}
} }
+4
View File
@@ -294,6 +294,8 @@ class CareSpecialtyShellTest extends TestCase
public function test_workspace_includes_timeline_tab_and_complete_action(): void public function test_workspace_includes_timeline_tab_and_complete_action(): void
{ {
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'doctor']);
app(SpecialtyModuleService::class)->activate( app(SpecialtyModuleService::class)->activate(
$this->organization, $this->organization,
$this->owner->public_id, $this->owner->public_id,
@@ -378,6 +380,8 @@ class CareSpecialtyShellTest extends TestCase
public function test_called_workspace_shows_call_again_until_session_starts(): void public function test_called_workspace_shows_call_again_until_session_starts(): void
{ {
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'doctor']);
app(SpecialtyModuleService::class)->activate( app(SpecialtyModuleService::class)->activate(
$this->organization, $this->organization,
$this->owner->public_id, $this->owner->public_id,