From 7989ab918437f15bc5937cdca2ede71a4570dafb Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 18 Jul 2026 17:48:57 +0000 Subject: [PATCH] Ship full Emergency specialty suite on the shared shell. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ED visit stages, triage-driven routing, vitals, observation, disposition, reports, and print summary so Emergency matches Dentistry’s operational depth without a parallel EHR. Co-authored-by: Cursor --- .../Care/EmergencyWorkspaceController.php | 234 +++++++++++++++++ .../Care/SpecialtyModuleController.php | 85 +++++- app/Services/Care/CareQueueProvisioner.php | 5 +- .../Emergency/EmergencyAnalyticsService.php | 168 ++++++++++++ .../Care/Emergency/EmergencyVitalsService.php | 115 +++++++++ .../Emergency/EmergencyWorkflowService.php | 76 ++++++ app/Services/Care/SpecialtyShellService.php | 4 +- .../Care/SpecialtyVisitStageService.php | 11 +- config/care_specialty_clinical.php | 20 ++ config/care_specialty_shell.php | 5 + .../care/specialty/emergency/print.blade.php | 88 +++++++ .../specialty/emergency/reports.blade.php | 88 +++++++ .../emergency/workspace-disposition.blade.php | 68 +++++ .../emergency/workspace-overview.blade.php | 117 +++++++++ .../emergency/workspace-vitals.blade.php | 67 +++++ .../specialty/partials/actions-menu.blade.php | 35 ++- .../specialty/sections/workspace.blade.php | 2 + routes/web.php | 6 + tests/Feature/CareEmergencySuiteTest.php | 241 ++++++++++++++++++ 19 files changed, 1413 insertions(+), 22 deletions(-) create mode 100644 app/Http/Controllers/Care/EmergencyWorkspaceController.php create mode 100644 app/Services/Care/Emergency/EmergencyAnalyticsService.php create mode 100644 app/Services/Care/Emergency/EmergencyVitalsService.php create mode 100644 app/Services/Care/Emergency/EmergencyWorkflowService.php create mode 100644 resources/views/care/specialty/emergency/print.blade.php create mode 100644 resources/views/care/specialty/emergency/reports.blade.php create mode 100644 resources/views/care/specialty/emergency/workspace-disposition.blade.php create mode 100644 resources/views/care/specialty/emergency/workspace-overview.blade.php create mode 100644 resources/views/care/specialty/emergency/workspace-vitals.blade.php create mode 100644 tests/Feature/CareEmergencySuiteTest.php diff --git a/app/Http/Controllers/Care/EmergencyWorkspaceController.php b/app/Http/Controllers/Care/EmergencyWorkspaceController.php new file mode 100644 index 0000000..08efe19 --- /dev/null +++ b/app/Http/Controllers/Care/EmergencyWorkspaceController.php @@ -0,0 +1,234 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'emergency'), 403); + } + + protected function assertVisit(Request $request, Visit $visit): void + { + abort_unless($visit->organization_id === $this->organization($request)->id, 404); + $this->authorizeBranch($request, (int) $visit->branch_id); + } + + public function setStage( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyVisitStageService $stages, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertEmergencyAccess($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'emergency', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'emergency', 'visit' => $visit, 'tab' => 'overview']) + ->with('success', 'Visit stage updated.'); + } + + public function saveVitals( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + EmergencyVitalsService $vitals, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertEmergencyAccess($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'bp_systolic' => ['nullable', 'integer', 'min:0', 'max:300'], + 'bp_diastolic' => ['nullable', 'integer', 'min:0', 'max:200'], + 'pulse' => ['nullable', 'integer', 'min:0', 'max:300'], + 'temperature' => ['nullable', 'numeric', 'min:30', 'max:45'], + 'spo2' => ['nullable', 'integer', 'min:0', 'max:100'], + 'respiratory_rate' => ['nullable', 'integer', 'min:0', 'max:80'], + 'weight_kg' => ['nullable', 'numeric', 'min:0', 'max:500'], + 'height_cm' => ['nullable', 'numeric', 'min:0', 'max:300'], + ]); + + try { + $vitals->record( + $this->organization($request), + $visit, + $this->ownerRef($request), + $validated, + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'emergency', 'visit' => $visit, 'tab' => 'vitals']) + ->with('success', 'Vitals recorded.'); + } + + public function saveDisposition( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + EmergencyWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertEmergencyAccess($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('emergency', 'disposition') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'disposition']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('emergency', 'disposition'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'emergency', + 'disposition', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + EmergencyWorkflowService::STAGE_DISPOSITION, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'emergency', + EmergencyWorkflowService::STAGE_DISPOSITION, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + // Stage already disposition or map empty. + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + $visit->refresh(); + if ($visit->status !== Visit::STATUS_COMPLETED) { + $visit->update([ + 'status' => Visit::STATUS_COMPLETED, + 'completed_at' => $visit->completed_at ?? now(), + ]); + } + + $appointment = $visit->appointment; + if ($appointment && $appointment->status !== \App\Models\Appointment::STATUS_COMPLETED) { + $appointment->update([ + 'status' => \App\Models\Appointment::STATUS_COMPLETED, + 'completed_at' => $appointment->completed_at ?? now(), + ]); + } + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'emergency', 'visit' => $visit, 'tab' => 'disposition']) + ->with('success', 'Disposition saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + EmergencyAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertEmergencyAccess($request, $modules); + + $organization = $this->organization($request); + $branchScope = app(\App\Services\Care\OrganizationResolver::class)->branchScope($this->member($request)); + + $report = $analytics->report( + $organization, + $this->ownerRef($request), + $branchScope, + $request->query('from'), + $request->query('to'), + ); + + return view('care.specialty.emergency.reports', [ + 'moduleKey' => 'emergency', + 'definition' => $modules->definition('emergency'), + 'shellNav' => $shell->navItems('emergency'), + 'report' => $report, + 'currency' => strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS'))), + ]); + } + + public function printSummary( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + EmergencyVitalsService $vitals, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertEmergencyAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.emergency.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'triage' => $clinical->findForVisit($visit, 'emergency', 'triage'), + 'clinicalNote' => $clinical->findForVisit($visit, 'emergency', 'clinical_note'), + 'observation' => $clinical->findForVisit($visit, 'emergency', 'observation'), + 'disposition' => $clinical->findForVisit($visit, 'emergency', 'disposition'), + 'vitals' => $vitals->forVisit($visit), + 'latestVitals' => $vitals->latestForVisit($visit), + ]); + } +} diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 13ec7bb..28bbeba 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -156,11 +156,13 @@ class SpecialtyModuleController extends Controller if ($openConsultation && $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED) { $clinical = app(SpecialtyClinicalRecordService::class); $shellTabs = app(SpecialtyShellService::class)->workspaceTabs($module); - $preferredTab = $module === 'dentistry' - ? 'odontogram' - : (collect(array_keys($shellTabs)) + $preferredTab = match ($module) { + 'dentistry' => 'odontogram', + 'emergency' => 'triage', + default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) - ?? 'clinical_notes'); + ?? 'clinical_notes'), + }; return redirect() ->route('care.specialty.workspace', [ @@ -214,7 +216,7 @@ class SpecialtyModuleController extends Controller } catch (\InvalidArgumentException) { // Stage map may be empty for some modules. } - } elseif ($nextStage && $visit->specialty_stage === 'waiting') { + } elseif ($nextStage && in_array($visit->specialty_stage, ['waiting', 'arrival'], true)) { try { $stageService->setStage($organization, $visit, $module, $nextStage, $owner, $owner); $visit = $visit->fresh(); @@ -224,11 +226,13 @@ class SpecialtyModuleController extends Controller } $clinical = app(SpecialtyClinicalRecordService::class); $shellTabs = app(SpecialtyShellService::class)->workspaceTabs($module); - $preferredTab = $module === 'dentistry' - ? 'odontogram' - : (collect(array_keys($shellTabs)) + $preferredTab = match ($module) { + 'dentistry' => 'odontogram', + 'emergency' => 'triage', + default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) - ?? 'clinical_notes'); + ?? 'clinical_notes'), + }; return redirect() ->route('care.specialty.workspace', [ @@ -285,6 +289,41 @@ class SpecialtyModuleController extends Controller $validated['status'] ?? \App\Models\SpecialtyClinicalRecord::STATUS_ACTIVE, ); + if ($module === 'emergency' && $recordType === 'triage') { + $workflow = app(\App\Services\Care\Emergency\EmergencyWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = $workflow->stageFromTriage($record->payload ?? []); + $current = $visit->specialty_stage; + if (! $current || in_array($current, ['arrival', 'waiting'], true)) { + try { + $stageService->setStage( + $organization, + $visit, + 'emergency', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } + } + + if ($module === 'emergency' && $recordType === 'observation') { + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + try { + $stageService->setStage( + $organization, + $visit, + 'emergency', + \App\Services\Care\Emergency\EmergencyWorkflowService::STAGE_OBSERVATION, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } + return redirect() ->route('care.specialty.workspace', [ 'module' => $module, @@ -555,6 +594,14 @@ class SpecialtyModuleController extends Controller $dentalLabCaseStatuses = []; $dentalRecalls = collect(); $dentalStageCodes = []; + $emergencyTriage = null; + $emergencyClinicalNote = null; + $emergencyObservation = null; + $emergencyDisposition = null; + $emergencyVitals = collect(); + $emergencyLatestVitals = null; + $emergencyStageCodes = []; + $emergencyStageFlow = []; $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); $clinical = app(SpecialtyClinicalRecordService::class); @@ -659,6 +706,18 @@ class SpecialtyModuleController extends Controller ); } + if ($module === 'emergency') { + $emergencyTriage = $clinical->findForVisit($workspaceVisit, 'emergency', 'triage'); + $emergencyClinicalNote = $clinical->findForVisit($workspaceVisit, 'emergency', 'clinical_note'); + $emergencyObservation = $clinical->findForVisit($workspaceVisit, 'emergency', 'observation'); + $emergencyDisposition = $clinical->findForVisit($workspaceVisit, 'emergency', 'disposition'); + $vitalsService = app(\App\Services\Care\Emergency\EmergencyVitalsService::class); + $emergencyVitals = $vitalsService->forVisit($workspaceVisit); + $emergencyLatestVitals = $vitalsService->latestForVisit($workspaceVisit); + $emergencyStageCodes = collect($shell->stages('emergency'))->pluck('code')->all(); + $emergencyStageFlow = app(\App\Services\Care\Emergency\EmergencyWorkflowService::class)->stageFlow(); + } + if ($patientHeader !== null) { $patientHeader['clinical_alerts'] = $clinicalAlerts; } @@ -729,6 +788,14 @@ class SpecialtyModuleController extends Controller 'dentalLabCaseStatuses' => $dentalLabCaseStatuses, 'dentalRecalls' => $dentalRecalls, 'dentalStageCodes' => $dentalStageCodes, + 'emergencyTriage' => $emergencyTriage, + 'emergencyClinicalNote' => $emergencyClinicalNote, + 'emergencyObservation' => $emergencyObservation, + 'emergencyDisposition' => $emergencyDisposition, + 'emergencyVitals' => $emergencyVitals, + 'emergencyLatestVitals' => $emergencyLatestVitals, + 'emergencyStageCodes' => $emergencyStageCodes, + 'emergencyStageFlow' => $emergencyStageFlow, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'branchLabel' => $branchLabel, diff --git a/app/Services/Care/CareQueueProvisioner.php b/app/Services/Care/CareQueueProvisioner.php index 2bdcfc5..ab5bdb3 100644 --- a/app/Services/Care/CareQueueProvisioner.php +++ b/app/Services/Care/CareQueueProvisioner.php @@ -292,11 +292,12 @@ class CareQueueProvisioner string $context, $priorByKey, ): array { - if ($context !== 'dentistry') { + $stages = app(SpecialtyShellService::class)->stages($context); + $hasQueuePoints = collect($stages)->contains(fn ($s) => is_string($s['queue_point'] ?? null) && $s['queue_point'] !== ''); + if (! $hasQueuePoints) { return []; } - $stages = app(SpecialtyShellService::class)->stages($context); $seen = []; $points = []; foreach ($stages as $stage) { diff --git a/app/Services/Care/Emergency/EmergencyAnalyticsService.php b/app/Services/Care/Emergency/EmergencyAnalyticsService.php new file mode 100644 index 0000000..5492daf --- /dev/null +++ b/app/Services/Care/Emergency/EmergencyAnalyticsService.php @@ -0,0 +1,168 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'emergency', $ownerRef, $branchId); + + $appointmentBase = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds)) + ->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0')) + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)); + + $arrivalsToday = (clone $appointmentBase) + ->where('checked_in_at', '>=', $todayStart) + ->count(); + + $completedToday = (clone $appointmentBase) + ->where('status', Appointment::STATUS_COMPLETED) + ->where('completed_at', '>=', $todayStart) + ->count(); + + $visitIds = (clone $appointmentBase)->whereNotNull('visit_id')->pluck('visit_id'); + + $triageRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'emergency') + ->where('record_type', 'triage') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $acuityDistribution = $triageRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['acuity'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $acuity) => [ + 'acuity' => $acuity, + 'total' => $rows->count(), + ]) + ->values() + ->sortBy('acuity') + ->values(); + + $openVisitIds = Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->pluck('id'); + + $highAcuityOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'emergency') + ->where('record_type', 'triage') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(function (SpecialtyClinicalRecord $r) { + $acuity = (string) ($r->payload['acuity'] ?? ''); + + return str_starts_with($acuity, '1') || str_starts_with($acuity, '2'); + }) + ->count(); + + $dispositionRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'emergency') + ->where('record_type', 'disposition') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $dispositionBreakdown = $dispositionRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['disposition'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $disposition) => [ + 'disposition' => $disposition, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $completedVisits = Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds) + ->where('status', Visit::STATUS_COMPLETED) + ->whereBetween('completed_at', [$rangeFrom, $rangeTo]) + ->whereNotNull('checked_in_at') + ->whereNotNull('completed_at') + ->get(); + + $avgLos = null; + if ($completedVisits->isNotEmpty()) { + $avgLos = round($completedVisits->avg(function (Visit $visit) { + return $visit->checked_in_at->diffInMinutes($visit->completed_at); + }), 1); + } + + $serviceLabels = collect($this->shell->provisionedServices($organization, 'emergency')) + ->pluck('label') + ->filter() + ->values() + ->all(); + + $revenueByService = BillLineItem::query() + ->where('owner_ref', $ownerRef) + ->where('source_type', 'specialty_service') + ->whereIn('description', $serviceLabels ?: ['__none__']) + ->whereBetween('created_at', [$rangeFrom, $rangeTo]) + ->whereHas('bill', function ($q) use ($organization, $visitIds) { + $q->where('organization_id', $organization->id) + ->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds) + ->whereNotIn('status', [Bill::STATUS_VOID]); + }) + ->selectRaw('description, count(*) as total, sum(total_minor) as amount_minor') + ->groupBy('description') + ->orderByDesc('amount_minor') + ->get(); + + return [ + 'arrivals_today' => $arrivalsToday, + 'completed_today' => $completedToday, + 'high_acuity_open' => $highAcuityOpen, + 'acuity_distribution' => $acuityDistribution, + 'disposition_breakdown' => $dispositionBreakdown, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/Emergency/EmergencyVitalsService.php b/app/Services/Care/Emergency/EmergencyVitalsService.php new file mode 100644 index 0000000..02c10e1 --- /dev/null +++ b/app/Services/Care/Emergency/EmergencyVitalsService.php @@ -0,0 +1,115 @@ + + */ + public function forVisit(Visit $visit): Collection + { + $consultationIds = Consultation::owned($visit->owner_ref) + ->where('visit_id', $visit->id) + ->pluck('id'); + + if ($consultationIds->isEmpty()) { + return collect(); + } + + return VitalSign::owned($visit->owner_ref) + ->whereIn('consultation_id', $consultationIds) + ->orderByDesc('recorded_at') + ->orderByDesc('id') + ->get(); + } + + public function latestForVisit(Visit $visit): ?VitalSign + { + return $this->forVisit($visit)->first(); + } + + /** + * @param array $vitals + */ + public function record( + Organization $organization, + Visit $visit, + string $ownerRef, + array $vitals, + ?string $actorRef = null, + ): VitalSign { + $consultation = $this->ensureConsultation($visit, $ownerRef, $actorRef); + + $saved = $this->consultations->saveVitals( + $consultation, + $ownerRef, + $vitals, + $actorRef, + ); + + if (! $saved) { + throw new \InvalidArgumentException('Enter at least one vital sign value.'); + } + + return $saved; + } + + protected function ensureConsultation(Visit $visit, string $ownerRef, ?string $actorRef): Consultation + { + $existing = Consultation::owned($ownerRef) + ->where('visit_id', $visit->id) + ->where('status', '!=', Consultation::STATUS_COMPLETED) + ->orderByDesc('id') + ->first(); + + if ($existing) { + return $existing; + } + + $completed = Consultation::owned($ownerRef) + ->where('visit_id', $visit->id) + ->orderByDesc('id') + ->first(); + + if ($completed) { + return $completed; + } + + $visit->loadMissing('appointment', 'patient'); + $appointment = $visit->appointment; + if (! $appointment instanceof Appointment) { + $appointment = Appointment::query()->where('visit_id', $visit->id)->first(); + } + + if ($appointment) { + try { + return $this->consultations->startFromAppointment($appointment, $ownerRef, $actorRef); + } catch (\InvalidArgumentException) { + // Fall through to draft consultation. + } + } + + return Consultation::create([ + 'owner_ref' => $ownerRef, + 'visit_id' => $visit->id, + 'appointment_id' => $appointment?->id, + 'practitioner_id' => $appointment?->practitioner_id, + 'patient_id' => $visit->patient_id, + 'status' => Consultation::STATUS_DRAFT, + 'started_at' => now(), + ]); + } +} diff --git a/app/Services/Care/Emergency/EmergencyWorkflowService.php b/app/Services/Care/Emergency/EmergencyWorkflowService.php new file mode 100644 index 0000000..eadd2db --- /dev/null +++ b/app/Services/Care/Emergency/EmergencyWorkflowService.php @@ -0,0 +1,76 @@ + $payload + */ + public function stageFromTriage(array $payload): string + { + $acuity = (string) ($payload['acuity'] ?? ''); + $intent = strtolower((string) ($payload['disposition_intent'] ?? '')); + + if (str_starts_with($acuity, '1') || str_starts_with($acuity, '2') || str_contains($intent, 'resus')) { + return self::STAGE_RESUS; + } + + if (str_contains($intent, 'observation')) { + return self::STAGE_OBSERVATION; + } + + if (str_contains($intent, 'discharge') || str_contains($intent, 'admit') || str_contains($intent, 'transfer')) { + return self::STAGE_TREATMENT; + } + + if (str_contains($intent, 'treatment')) { + return self::STAGE_TREATMENT; + } + + return self::STAGE_TREATMENT; + } + + /** + * Whether a disposition payload should close the visit episode. + * + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $disposition = strtolower((string) ($payload['disposition'] ?? '')); + + return $disposition !== ''; + } + + /** + * Default stage progression for action buttons. + * + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_ARRIVAL => ['next' => self::STAGE_TREATMENT, 'label' => 'Move to treatment'], + self::STAGE_RESUS => ['next' => self::STAGE_TREATMENT, 'label' => 'Move to treatment bay'], + self::STAGE_TREATMENT => ['next' => self::STAGE_OBSERVATION, 'label' => 'Move to observation'], + self::STAGE_OBSERVATION => ['next' => self::STAGE_DISPOSITION, 'label' => 'Ready for disposition'], + self::STAGE_DISPOSITION => ['next' => self::STAGE_DISPOSITION, 'label' => 'Complete disposition'], + ]; + } +} diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php index 226fb9e..bc79c54 100644 --- a/app/Services/Care/SpecialtyShellService.php +++ b/app/Services/Care/SpecialtyShellService.php @@ -270,13 +270,13 @@ class SpecialtyShellService ) { $code = (string) ($stage['code'] ?? ''); - if ($moduleKey === 'dentistry' && $visitIds->isNotEmpty()) { + if (in_array($moduleKey, ['dentistry', 'emergency'], true) && $visitIds->isNotEmpty()) { $count = Visit::owned($ownerRef) ->where('organization_id', $organization->id) ->whereIn('id', $visitIds) ->where('specialty_stage', $code) ->when( - $code !== 'completed', + ! in_array($code, ['completed', 'disposition'], true), fn ($q) => $q->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]), ) ->count(); diff --git a/app/Services/Care/SpecialtyVisitStageService.php b/app/Services/Care/SpecialtyVisitStageService.php index d36264e..05ed4e1 100644 --- a/app/Services/Care/SpecialtyVisitStageService.php +++ b/app/Services/Care/SpecialtyVisitStageService.php @@ -7,6 +7,7 @@ use App\Models\CareQueueTicket; use App\Models\Organization; use App\Models\Visit; use App\Services\Care\AuditLogger; +use App\Services\Care\Emergency\EmergencyWorkflowService; /** * Persist specialty visit stages (dentistry chair → recovery) and re-route queue tickets. @@ -79,8 +80,16 @@ class SpecialtyVisitStageService public function stageOnConsultationStart(string $moduleKey): ?string { + if ($moduleKey === 'emergency') { + $stages = $this->allowedStages($moduleKey); + + return in_array(EmergencyWorkflowService::STAGE_TREATMENT, $stages, true) + ? EmergencyWorkflowService::STAGE_TREATMENT + : ($stages[1] ?? ($stages[0] ?? null)); + } + $stages = $this->allowedStages($moduleKey); - foreach (['chair', 'in_care', 'exam', 'assessment'] as $preferred) { + foreach (['chair', 'in_care', 'exam', 'assessment', 'treatment'] as $preferred) { if (in_array($preferred, $stages, true)) { return $preferred; } diff --git a/config/care_specialty_clinical.php b/config/care_specialty_clinical.php index 96db63e..158a03d 100644 --- a/config/care_specialty_clinical.php +++ b/config/care_specialty_clinical.php @@ -14,6 +14,8 @@ return [ 'emergency' => [ 'triage' => 'triage', 'clinical_notes' => 'clinical_note', + 'observation' => 'observation', + 'disposition' => 'disposition', ], 'blood_bank' => [ 'requests' => 'blood_request', @@ -121,6 +123,24 @@ return [ ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 3], ['name' => 'procedures', 'label' => 'Procedures performed', 'type' => 'textarea', 'rows' => 2], ], + 'observation' => [ + ['name' => 'bed_label', 'label' => 'Bed / bay label', 'type' => 'text'], + ['name' => 'started_at', 'label' => 'Observation started', 'type' => 'text'], + ['name' => 'hours', 'label' => 'Hours observed', 'type' => 'number'], + ['name' => 'monitoring_plan', 'label' => 'Monitoring plan', 'type' => 'textarea', 'rows' => 2], + ['name' => 'progress', 'label' => 'Progress notes', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'ready_for_disposition', 'label' => 'Ready for disposition', 'type' => 'boolean'], + ], + 'disposition' => [ + ['name' => 'disposition', 'label' => 'Final disposition', 'type' => 'select', 'required' => true, 'options' => ['Discharge home', 'Admit to ward', 'Admit to ICU', 'Transfer to facility', 'Left against advice', 'Died', 'Absconded']], + ['name' => 'condition', 'label' => 'Condition at disposition', 'type' => 'select', 'options' => ['Stable', 'Improving', 'Unstable', 'Critical', 'Deceased']], + ['name' => 'destination', 'label' => 'Destination (ward / facility)', 'type' => 'text'], + ['name' => 'diagnosis', 'label' => 'Discharge / transfer diagnosis', 'type' => 'text', 'required' => true], + ['name' => 'advice', 'label' => 'Advice / follow-up', 'type' => 'textarea', 'rows' => 3], + ['name' => 'medications', 'label' => 'Medications on discharge', 'type' => 'textarea', 'rows' => 2], + ['name' => 'time', 'label' => 'Disposition time', 'type' => 'text'], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], + ], ], 'blood_bank' => [ 'blood_request' => [ diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index 8eed965..a3774c6 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -58,12 +58,17 @@ return [ ['code' => 'er.triage', 'label' => 'Emergency triage', 'amount_minor' => 0, 'type' => 'consultation'], ['code' => 'er.consultation', 'label' => 'Emergency consultation', 'amount_minor' => 8000, 'type' => 'consultation'], ['code' => 'er.procedure', 'label' => 'Emergency procedure', 'amount_minor' => 15000, 'type' => 'procedure'], + ['code' => 'er.resuscitation', 'label' => 'Resuscitation', 'amount_minor' => 25000, 'type' => 'procedure'], ['code' => 'er.observation', 'label' => 'Observation bed (per hour)', 'amount_minor' => 3000, 'type' => 'misc'], + ['code' => 'er.transfer', 'label' => 'Transfer coordination', 'amount_minor' => 5000, 'type' => 'misc'], ], 'workspace_tabs' => [ 'overview' => 'Overview', 'triage' => 'Triage', + 'vitals' => 'Vitals', 'clinical_notes' => 'Clinical notes', + 'observation' => 'Observation', + 'disposition' => 'Disposition', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', diff --git a/resources/views/care/specialty/emergency/print.blade.php b/resources/views/care/specialty/emergency/print.blade.php new file mode 100644 index 0000000..02755e4 --- /dev/null +++ b/resources/views/care/specialty/emergency/print.blade.php @@ -0,0 +1,88 @@ + + + + + ED summary · {{ $patient->fullName() }} + + + +

Back

+ +

{{ $patient->fullName() }}

+

+ {{ $patient->patient_number }} + · Visit #{{ $visit->id }} + · Stage {{ $visit->specialty_stage ?? '—' }} + · {{ $visit->checked_in_at?->format('d M Y H:i') ?? '—' }} +

+ +

Triage

+ @if ($triage) +
+
Acuity
{{ $triage->payload['acuity'] ?? '—' }}
+
Chief complaint
{{ $triage->payload['chief_complaint'] ?? '—' }}
+
Mode of arrival
{{ $triage->payload['mode_of_arrival'] ?? '—' }}
+
ABC
+
+ A {{ ($triage->payload['airway_ok'] ?? true) ? 'OK' : 'Compromised' }} + · B {{ ($triage->payload['breathing_ok'] ?? true) ? 'OK' : 'Compromised' }} + · C {{ ($triage->payload['circulation_ok'] ?? true) ? 'OK' : 'Compromised' }} +
+
GCS / Pain
{{ $triage->payload['gcs'] ?? '—' }} / {{ $triage->payload['pain_score'] ?? '—' }}
+
Notes
{{ $triage->payload['notes'] ?? '—' }}
+
+ @else +

No triage recorded.

+ @endif + +

Latest vitals

+ @if ($latestVitals) +
+
BP
{{ $latestVitals->bp_systolic ?? '—' }}/{{ $latestVitals->bp_diastolic ?? '—' }}
+
Pulse
{{ $latestVitals->pulse ?? '—' }}
+
SpO₂
{{ $latestVitals->spo2 ?? '—' }}%
+
Temp
{{ $latestVitals->temperature ?? '—' }}
+
RR
{{ $latestVitals->respiratory_rate ?? '—' }}
+
Recorded
{{ $latestVitals->recorded_at?->format('d M Y H:i') }}
+
+ @else +

No vitals recorded.

+ @endif + +

Clinical note

+ @if ($clinicalNote) +
+
History
{{ $clinicalNote->payload['history'] ?? '—' }}
+
Examination
{{ $clinicalNote->payload['examination'] ?? '—' }}
+
Diagnosis
{{ $clinicalNote->payload['working_diagnosis'] ?? '—' }}
+
Plan
{{ $clinicalNote->payload['plan'] ?? '—' }}
+
+ @else +

No clinical note.

+ @endif + +

Disposition

+ @if ($disposition) +
+
Disposition
{{ $disposition->payload['disposition'] ?? '—' }}
+
Condition
{{ $disposition->payload['condition'] ?? '—' }}
+
Destination
{{ $disposition->payload['destination'] ?? '—' }}
+
Diagnosis
{{ $disposition->payload['diagnosis'] ?? '—' }}
+
Advice
{{ $disposition->payload['advice'] ?? '—' }}
+
+ @else +

No disposition recorded.

+ @endif + + + + diff --git a/resources/views/care/specialty/emergency/reports.blade.php b/resources/views/care/specialty/emergency/reports.blade.php new file mode 100644 index 0000000..67e2ebf --- /dev/null +++ b/resources/views/care/specialty/emergency/reports.blade.php @@ -0,0 +1,88 @@ + +
+
+
+

Emergency reports

+

Arrivals, acuity mix, dispositions, length of stay, and ED service revenue.

+
+ ← Back to queue +
+ +
+
+ + +
+
+ + +
+ +
+ +
+
+

Arrivals today

+

{{ number_format($report['arrivals_today']) }}

+
+
+

Completed today

+

{{ number_format($report['completed_today']) }}

+
+
+

High-acuity open

+

{{ number_format($report['high_acuity_open']) }}

+
+
+

Avg LOS (range)

+

+ {{ $report['avg_los_minutes'] !== null ? $report['avg_los_minutes'].' min' : '—' }} +

+
+
+ +
+
+

Acuity distribution

+
    + @forelse ($report['acuity_distribution'] as $row) +
  • + {{ $row['acuity'] }} + {{ $row['total'] }} +
  • + @empty +
  • No triage records in range.
  • + @endforelse +
+
+ +
+

Dispositions

+
    + @forelse ($report['disposition_breakdown'] as $row) +
  • + {{ $row['disposition'] }} + {{ $row['total'] }} +
  • + @empty +
  • No dispositions in range.
  • + @endforelse +
+
+
+ +
+

ED service revenue

+
    + @forelse ($report['revenue_by_service'] as $row) +
  • + {{ $row->description }} ×{{ $row->total }} + {{ $currency }} {{ number_format($row->amount_minor / 100, 2) }} +
  • + @empty +
  • No ED bill lines in range.
  • + @endforelse +
+
+
+
diff --git a/resources/views/care/specialty/emergency/workspace-disposition.blade.php b/resources/views/care/specialty/emergency/workspace-disposition.blade.php new file mode 100644 index 0000000..543c0b9 --- /dev/null +++ b/resources/views/care/specialty/emergency/workspace-disposition.blade.php @@ -0,0 +1,68 @@ +@php + $record = $emergencyDisposition; + $payload = old('payload', $record?->payload ?? []); +@endphp + +
+
+
+

Disposition

+

Final disposition closes the emergency visit episode.

+
+ Print summary +
+ +
+ @csrf + + +
+
+ + + @error('payload.disposition')

{{ $message }}

@enderror +
+
+ + +
+
+ + +
+
+ + +
+
+ + + @error('payload.diagnosis')

{{ $message }}

@enderror +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/resources/views/care/specialty/emergency/workspace-overview.blade.php b/resources/views/care/specialty/emergency/workspace-overview.blade.php new file mode 100644 index 0000000..da205ed --- /dev/null +++ b/resources/views/care/specialty/emergency/workspace-overview.blade.php @@ -0,0 +1,117 @@ +@php + $triage = $emergencyTriage; + $payload = $triage?->payload ?? []; + $currentStage = $workspaceVisit->specialty_stage; + $stageFlow = $emergencyStageFlow ?? []; + $latest = $emergencyLatestVitals; +@endphp + +
+
+
+

Emergency overview

+

Acuity, stage, ABC flags, and latest vitals for this visit.

+
+ +
+ +
+
+

Acuity

+

{{ $payload['acuity'] ?? 'Not triaged' }}

+

{{ $payload['chief_complaint'] ?? '—' }}

+
+
+

ABC

+

+ A {{ ($payload['airway_ok'] ?? true) ? '✓' : '✗' }} + · B {{ ($payload['breathing_ok'] ?? true) ? '✓' : '✗' }} + · C {{ ($payload['circulation_ok'] ?? true) ? '✓' : '✗' }} +

+

Pain {{ $payload['pain_score'] ?? '—' }} · GCS {{ $payload['gcs'] ?? '—' }}

+
+
+

Latest vitals

+ @if ($latest) +

+ BP {{ $latest->bp_systolic ?? '—' }}/{{ $latest->bp_diastolic ?? '—' }} + · P {{ $latest->pulse ?? '—' }} + · SpO₂ {{ $latest->spo2 ?? '—' }}% +

+

{{ $latest->recorded_at?->format('d M H:i') }}

+ @else +

None recorded

+ @endif +
+
+ +
+

Visit stage

+

+ {{ $currentStage ? ucfirst(str_replace('_', ' ', $currentStage)) : 'Not set' }} +

+
+ @foreach (($emergencyStageCodes ?: ['arrival', 'resus', 'treatment', 'observation', 'disposition']) as $stageCode) +
+ @csrf + + +
+ @endforeach + @if ($currentStage && isset($stageFlow[$currentStage]) && $stageFlow[$currentStage]['next'] !== $currentStage) +
+ @csrf + + +
+ @endif +
+
+ +
+
+
Mode of arrival
+
{{ $payload['mode_of_arrival'] ?? '—' }}
+
+
+
Disposition intent
+
{{ $payload['disposition_intent'] ?? '—' }}
+
+
+
Queue
+
+ {{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }} + @if ($workspaceVisit->appointment?->queue_ticket_status) + ({{ $workspaceVisit->appointment->queue_ticket_status }}) + @endif +
+
+
+
Checked in
+
{{ $workspaceVisit->checked_in_at?->format('d M Y H:i') ?? '—' }}
+
+
+ + @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

+ {{ $alert['message'] ?? '' }} +

+ @endforeach +
+ @endif +
diff --git a/resources/views/care/specialty/emergency/workspace-vitals.blade.php b/resources/views/care/specialty/emergency/workspace-vitals.blade.php new file mode 100644 index 0000000..f9617c4 --- /dev/null +++ b/resources/views/care/specialty/emergency/workspace-vitals.blade.php @@ -0,0 +1,67 @@ +
+
+
+

Vitals

+

Record BP, pulse, SpO₂, temperature, and respiratory rate on this ED visit.

+
+
+ +
+ @csrf +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

History

+
    + @forelse ($emergencyVitals as $row) +
  • + + BP {{ $row->bp_systolic ?? '—' }}/{{ $row->bp_diastolic ?? '—' }} + · P {{ $row->pulse ?? '—' }} + · SpO₂ {{ $row->spo2 ?? '—' }}% + · T {{ $row->temperature ?? '—' }} + · RR {{ $row->respiratory_rate ?? '—' }} + + {{ $row->recorded_at?->format('d M Y H:i') }} +
  • + @empty +
  • No vitals recorded yet.
  • + @endforelse +
+
+
diff --git a/resources/views/care/specialty/partials/actions-menu.blade.php b/resources/views/care/specialty/partials/actions-menu.blade.php index 7ff41d5..e1c047a 100644 --- a/resources/views/care/specialty/partials/actions-menu.blade.php +++ b/resources/views/care/specialty/partials/actions-menu.blade.php @@ -22,14 +22,21 @@ && $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED; $chartTab = collect($workspaceTabs ?? []) ->keys() - ->first(fn ($key) => ! in_array($key, ['overview', 'timeline', 'plan', 'treat', 'notes', 'imaging', 'orders', 'billing', 'documents', 'perio', 'lab', 'recalls'], true)) - ?: 'odontogram'; - $stageFlow = [ - 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], - 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], - 'procedure' => ['next' => 'recovery', 'label' => 'Move to recovery'], - 'recovery' => ['next' => 'completed', 'label' => 'Complete visit'], - ]; + ->first(fn ($key) => ! in_array($key, ['overview', 'timeline', 'plan', 'treat', 'notes', 'imaging', 'orders', 'billing', 'documents', 'perio', 'lab', 'recalls', 'vitals', 'disposition', 'observation'], true)) + ?: ($moduleKey === 'emergency' ? 'triage' : 'odontogram'); + $stageFlow = $moduleKey === 'emergency' + ? ($emergencyStageFlow ?? [ + 'arrival' => ['next' => 'treatment', 'label' => 'Move to treatment'], + 'resus' => ['next' => 'treatment', 'label' => 'Move to treatment bay'], + 'treatment' => ['next' => 'observation', 'label' => 'Move to observation'], + 'observation' => ['next' => 'disposition', 'label' => 'Ready for disposition'], + ]) + : [ + 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], + 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], + 'procedure' => ['next' => 'recovery', 'label' => 'Move to recovery'], + 'recovery' => ['next' => 'completed', 'label' => 'Complete visit'], + ]; $currentStage = $workspaceVisit?->specialty_stage; @endphp @@ -63,6 +70,18 @@ + @elseif ($moduleKey === 'emergency' && $currentStage && isset($stageFlow[$currentStage]) && ($stageFlow[$currentStage]['next'] ?? null) !== $currentStage) +
+ @csrf + + +
+ @elseif ($moduleKey === 'emergency' && ! $currentStage) +
+ @csrf + + +
@endif @if ($canCompleteConsultation) diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index fc8587f..26f9dfc 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -38,6 +38,8 @@ @include('care.partials.patient-timeline', ['events' => $timeline ?? []]) @elseif ($moduleKey === 'dentistry' && in_array($activeTab, ['odontogram', 'plan', 'treat', 'notes', 'imaging', 'overview', 'perio', 'lab', 'recalls'], true)) @include('care.specialty.dentistry.workspace-'.$activeTab) + @elseif ($moduleKey === 'emergency' && in_array($activeTab, ['overview', 'vitals', 'disposition'], true)) + @include('care.specialty.emergency.workspace-'.$activeTab) @elseif ($activeTab === 'billing')

Add specialty service

diff --git a/routes/web.php b/routes/web.php index 4bff339..dc92225 100644 --- a/routes/web.php +++ b/routes/web.php @@ -36,6 +36,7 @@ use App\Http\Controllers\Care\SettingsController; use App\Http\Controllers\Care\SettingsGpPricingController; use App\Http\Controllers\Care\SettingsModulesController; use App\Http\Controllers\Care\DentistryWorkspaceController; +use App\Http\Controllers\Care\EmergencyWorkspaceController; use App\Http\Controllers\Care\SpecialtyModuleController; use App\Http\Controllers\Care\VisitWorkflowController; use App\Http\Controllers\NotificationController; @@ -114,6 +115,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/specialty/{module}/history', [SpecialtyModuleController::class, 'history'])->name('care.specialty.history'); Route::get('/specialty/{module}/billing', [SpecialtyModuleController::class, 'billing'])->name('care.specialty.billing'); Route::get('/specialty/dentistry/reports', [DentistryWorkspaceController::class, 'reports'])->name('care.specialty.dentistry.reports'); + Route::get('/specialty/emergency/reports', [EmergencyWorkspaceController::class, 'reports'])->name('care.specialty.emergency.reports'); Route::get('/specialty/{module}/workspace/{visit?}', [SpecialtyModuleController::class, 'workspace'])->name('care.specialty.workspace'); Route::post('/specialty/{module}/workspace/{visit}/clinical', [SpecialtyModuleController::class, 'saveClinical'])->name('care.specialty.clinical.save'); Route::post('/specialty/{module}/workspace/{visit}/documents', [SpecialtyModuleController::class, 'uploadDocument'])->name('care.specialty.documents.upload'); @@ -139,6 +141,10 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/specialty/dentistry/workspace/{visit}/recalls/{recall}/complete', [DentistryWorkspaceController::class, 'completeRecall'])->name('care.specialty.dentistry.recalls.complete'); Route::post('/specialty/dentistry/workspace/{visit}/recalls/{recall}/cancel', [DentistryWorkspaceController::class, 'cancelRecall'])->name('care.specialty.dentistry.recalls.cancel'); Route::get('/specialty/dentistry/workspace/{visit}/print', [DentistryWorkspaceController::class, 'printChart'])->name('care.specialty.dentistry.print'); + Route::post('/specialty/emergency/workspace/{visit}/stage', [EmergencyWorkspaceController::class, 'setStage'])->name('care.specialty.emergency.stage'); + Route::post('/specialty/emergency/workspace/{visit}/vitals', [EmergencyWorkspaceController::class, 'saveVitals'])->name('care.specialty.emergency.vitals'); + Route::post('/specialty/emergency/workspace/{visit}/disposition', [EmergencyWorkspaceController::class, 'saveDisposition'])->name('care.specialty.emergency.disposition'); + Route::get('/specialty/emergency/workspace/{visit}/print', [EmergencyWorkspaceController::class, 'printSummary'])->name('care.specialty.emergency.print'); Route::get('/specialty/{module}', [SpecialtyModuleController::class, 'show'])->name('care.specialty.show'); Route::post('/specialty/{module}/call-next', [SpecialtyModuleController::class, 'callNext'])->name('care.specialty.call-next'); diff --git a/tests/Feature/CareEmergencySuiteTest.php b/tests/Feature/CareEmergencySuiteTest.php new file mode 100644 index 0000000..de98910 --- /dev/null +++ b/tests/Feature/CareEmergencySuiteTest.php @@ -0,0 +1,241 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'er-owner', + 'name' => 'Owner', + 'email' => 'er-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Emergency Clinic', + 'slug' => 'emergency-clinic', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'clinic', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'queue_integration_enabled' => true, + ], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'hospital_admin', + 'branch_id' => null, + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + app(SpecialtyModuleService::class)->activate( + $this->organization, + $this->owner->public_id, + 'emergency', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'emergency') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-ER-SUITE', + 'first_name' => 'Kwame', + 'last_name' => 'Boateng', + 'gender' => 'male', + 'date_of_birth' => '1988-01-01', + ]); + + $this->visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + 'specialty_stage' => 'arrival', + ]); + + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'department_id' => $department->id, + 'visit_id' => $this->visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_IN_CONSULTATION, + 'scheduled_at' => now(), + 'waiting_at' => now(), + 'checked_in_at' => now(), + 'started_at' => now(), + ]); + } + + public function test_overview_and_vitals_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'emergency', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Emergency overview') + ->assertSee('Visit stage') + ->assertSee('ED reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'emergency', + 'visit' => $this->visit, + 'tab' => 'vitals', + ])) + ->assertOk() + ->assertSee('Save vitals'); + } + + public function test_triage_sets_stage_and_alerts(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'emergency', + 'visit' => $this->visit, + ]), [ + 'tab' => 'triage', + 'payload' => [ + 'acuity' => '1 - Resuscitation', + 'chief_complaint' => 'Chest pain', + 'airway_ok' => '1', + 'breathing_ok' => '0', + 'circulation_ok' => '1', + 'pain_score' => 9, + 'disposition_intent' => 'Resus', + ], + ]) + ->assertRedirect(); + + $this->assertSame('resus', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'triage') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('er.high_acuity', $codes); + $this->assertContains('er.abc_compromise', $codes); + $this->assertContains('er.severe_pain', $codes); + } + + public function test_stage_advance_and_vitals_and_disposition(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.emergency.stage', $this->visit), [ + 'stage' => 'treatment', + ]) + ->assertRedirect(); + + $this->assertSame('treatment', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.emergency.vitals', $this->visit), [ + 'bp_systolic' => 120, + 'bp_diastolic' => 80, + 'pulse' => 88, + 'spo2' => 98, + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'emergency', + 'visit' => $this->visit, + 'tab' => 'vitals', + ])); + + $this->assertDatabaseHas('care_vital_signs', [ + 'bp_systolic' => 120, + 'pulse' => 88, + ]); + $this->assertTrue(VitalSign::query()->where('bp_systolic', 120)->exists()); + + $this->actingAs($this->owner) + ->post(route('care.specialty.emergency.disposition', $this->visit), [ + 'tab' => 'disposition', + 'payload' => [ + 'disposition' => 'Discharge home', + 'condition' => 'Stable', + 'diagnosis' => 'Musculoskeletal chest wall pain', + 'advice' => 'Return if worsens', + ], + ]) + ->assertRedirect(); + + $this->assertSame('disposition', $this->visit->fresh()->specialty_stage); + $this->assertSame(Visit::STATUS_COMPLETED, $this->visit->fresh()->status); + $this->assertDatabaseHas('care_specialty_clinical_records', [ + 'visit_id' => $this->visit->id, + 'record_type' => 'disposition', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.emergency.reports')) + ->assertOk() + ->assertSee('Emergency reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.emergency.print', $this->visit)) + ->assertOk() + ->assertSee('ED summary') + ->assertSee($this->patient->fullName()); + } +}