diff --git a/app/Http/Controllers/Care/AmbulanceWorkspaceController.php b/app/Http/Controllers/Care/AmbulanceWorkspaceController.php new file mode 100644 index 0000000..3d961b2 --- /dev/null +++ b/app/Http/Controllers/Care/AmbulanceWorkspaceController.php @@ -0,0 +1,214 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'ambulance'), 403); + } + + protected function assertAmbulanceManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'ambulance'), 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, + SpecialtyShellService $shell, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertAmbulanceManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'ambulance', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'ambulance', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('ambulance', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveHandover( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + AmbulanceWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertAmbulanceManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('ambulance', 'amb_handover') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'handover']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('ambulance', 'amb_handover'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'ambulance', + 'amb_handover', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + AmbulanceWorkflowService::STAGE_HANDOVER, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'ambulance', + AmbulanceWorkflowService::STAGE_HANDOVER, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'ambulance', + AmbulanceWorkflowService::STAGE_COMPLETED, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + $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' => 'ambulance', 'visit' => $visit, 'tab' => 'handover']) + ->with('success', 'Handover saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + AmbulanceAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertAmbulanceAccess($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.ambulance.reports', [ + 'moduleKey' => 'ambulance', + 'definition' => $modules->definition('ambulance'), + 'shellNav' => $shell->navItems('ambulance'), + 'report' => $report, + 'currency' => strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS'))), + ]); + } + + public function printSummary( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertAmbulanceAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.ambulance.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'dispatch' => $clinical->findForVisit($visit, 'ambulance', 'amb_dispatch'), + 'scene' => $clinical->findForVisit($visit, 'ambulance', 'amb_scene'), + 'enRoute' => $clinical->findForVisit($visit, 'ambulance', 'amb_en_route'), + 'handover' => $clinical->findForVisit($visit, 'ambulance', 'amb_handover'), + ]); + } +} diff --git a/app/Http/Controllers/Care/ChildWelfareWorkspaceController.php b/app/Http/Controllers/Care/ChildWelfareWorkspaceController.php new file mode 100644 index 0000000..c73d74d --- /dev/null +++ b/app/Http/Controllers/Care/ChildWelfareWorkspaceController.php @@ -0,0 +1,214 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'child_welfare'), 403); + } + + protected function assertChildWelfareManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'child_welfare'), 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, + SpecialtyShellService $shell, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertChildWelfareManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'child_welfare', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'child_welfare', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('child_welfare', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveFollowUp( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + ChildWelfareWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertChildWelfareManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('child_welfare', 'cwc_followup') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'followup']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('child_welfare', 'cwc_followup'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'child_welfare', + 'cwc_followup', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + ChildWelfareWorkflowService::STAGE_FOLLOWUP, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'child_welfare', + ChildWelfareWorkflowService::STAGE_FOLLOWUP, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'child_welfare', + ChildWelfareWorkflowService::STAGE_COMPLETED, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + $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' => 'child_welfare', 'visit' => $visit, 'tab' => 'followup']) + ->with('success', 'Intervention / follow-up saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + ChildWelfareAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertChildWelfareAccess($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.child-welfare.reports', [ + 'moduleKey' => 'child_welfare', + 'definition' => $modules->definition('child_welfare'), + 'shellNav' => $shell->navItems('child_welfare'), + 'report' => $report, + 'currency' => strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS'))), + ]); + } + + public function printSummary( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertChildWelfareAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.child-welfare.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'intake' => $clinical->findForVisit($visit, 'child_welfare', 'cwc_intake'), + 'assessment' => $clinical->findForVisit($visit, 'child_welfare', 'cwc_assessment'), + 'plan' => $clinical->findForVisit($visit, 'child_welfare', 'cwc_plan'), + 'followup' => $clinical->findForVisit($visit, 'child_welfare', 'cwc_followup'), + ]); + } +} diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 775e049..852f784 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -171,6 +171,8 @@ class SpecialtyModuleController extends Controller 'dermatology' => 'history', 'podiatry' => 'history', 'fertility' => 'history', + 'child_welfare' => 'intake', + 'ambulance' => 'dispatch', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -260,6 +262,8 @@ class SpecialtyModuleController extends Controller 'dermatology' => 'history', 'podiatry' => 'history', 'fertility' => 'history', + 'child_welfare' => 'intake', + 'ambulance' => 'dispatch', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -1127,6 +1131,70 @@ class SpecialtyModuleController extends Controller } } + if ($module === 'child_welfare' && in_array($recordType, ['cwc_intake', 'cwc_assessment', 'cwc_plan'], true)) { + $workflow = app(\App\Services\Care\ChildWelfare\ChildWelfareWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'cwc_intake' => $workflow->stageFromIntake($record->payload ?? []), + 'cwc_assessment' => $workflow->stageFromAssessment($record->payload ?? []), + 'cwc_plan' => $workflow->stageFromPlan($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'intake' => 1, + 'assessment' => 2, + 'plan' => 3, + 'followup' => 4, + 'completed' => 5, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage($organization, $visit, 'child_welfare', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage($organization, $visit, 'child_welfare', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } + } + + if ($module === 'ambulance' && in_array($recordType, ['amb_dispatch', 'amb_scene', 'amb_en_route'], true)) { + $workflow = app(\App\Services\Care\Ambulance\AmbulanceWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'amb_dispatch' => $workflow->stageFromDispatch($record->payload ?? []), + 'amb_scene' => $workflow->stageFromScene($record->payload ?? []), + 'amb_en_route' => $workflow->stageFromEnRoute($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'dispatch' => 1, + 'on_scene' => 2, + 'transport' => 3, + 'handover' => 4, + 'completed' => 5, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage($organization, $visit, 'ambulance', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage($organization, $visit, 'ambulance', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } + } + return redirect() ->route('care.specialty.workspace', [ 'module' => $module, @@ -1644,6 +1712,18 @@ class SpecialtyModuleController extends Controller $fertilityCycle = null; $fertilityStageCodes = []; $fertilityStageFlow = []; + $childWelfareIntake = null; + $childWelfareAssessment = null; + $childWelfarePlan = null; + $childWelfareFollowup = null; + $childWelfareStageCodes = []; + $childWelfareStageFlow = []; + $ambulanceDispatch = null; + $ambulanceScene = null; + $ambulanceEnRoute = null; + $ambulanceHandover = null; + $ambulanceStageCodes = []; + $ambulanceStageFlow = []; $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); $clinical = app(SpecialtyClinicalRecordService::class); @@ -1926,6 +2006,24 @@ class SpecialtyModuleController extends Controller $fertilityStageFlow = app(\App\Services\Care\Fertility\FertilityWorkflowService::class)->stageFlow(); } + if ($module === 'child_welfare') { + $childWelfareIntake = $clinical->findForVisit($workspaceVisit, 'child_welfare', 'cwc_intake'); + $childWelfareAssessment = $clinical->findForVisit($workspaceVisit, 'child_welfare', 'cwc_assessment'); + $childWelfarePlan = $clinical->findForVisit($workspaceVisit, 'child_welfare', 'cwc_plan'); + $childWelfareFollowup = $clinical->findForVisit($workspaceVisit, 'child_welfare', 'cwc_followup'); + $childWelfareStageCodes = collect($shell->stages('child_welfare'))->pluck('code')->all(); + $childWelfareStageFlow = app(\App\Services\Care\ChildWelfare\ChildWelfareWorkflowService::class)->stageFlow(); + } + + if ($module === 'ambulance') { + $ambulanceDispatch = $clinical->findForVisit($workspaceVisit, 'ambulance', 'amb_dispatch'); + $ambulanceScene = $clinical->findForVisit($workspaceVisit, 'ambulance', 'amb_scene'); + $ambulanceEnRoute = $clinical->findForVisit($workspaceVisit, 'ambulance', 'amb_en_route'); + $ambulanceHandover = $clinical->findForVisit($workspaceVisit, 'ambulance', 'amb_handover'); + $ambulanceStageCodes = collect($shell->stages('ambulance'))->pluck('code')->all(); + $ambulanceStageFlow = app(\App\Services\Care\Ambulance\AmbulanceWorkflowService::class)->stageFlow(); + } + if ($patientHeader !== null) { $patientHeader['clinical_alerts'] = $clinicalAlerts; } @@ -2135,6 +2233,18 @@ class SpecialtyModuleController extends Controller 'fertilityCycle' => $fertilityCycle, 'fertilityStageCodes' => $fertilityStageCodes, 'fertilityStageFlow' => $fertilityStageFlow, + 'childWelfareIntake' => $childWelfareIntake, + 'childWelfareAssessment' => $childWelfareAssessment, + 'childWelfarePlan' => $childWelfarePlan, + 'childWelfareFollowup' => $childWelfareFollowup, + 'childWelfareStageCodes' => $childWelfareStageCodes, + 'childWelfareStageFlow' => $childWelfareStageFlow, + 'ambulanceDispatch' => $ambulanceDispatch, + 'ambulanceScene' => $ambulanceScene, + 'ambulanceEnRoute' => $ambulanceEnRoute, + 'ambulanceHandover' => $ambulanceHandover, + 'ambulanceStageCodes' => $ambulanceStageCodes, + 'ambulanceStageFlow' => $ambulanceStageFlow, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'branchLabel' => $branchLabel, diff --git a/app/Services/Care/Ambulance/AmbulanceAnalyticsService.php b/app/Services/Care/Ambulance/AmbulanceAnalyticsService.php new file mode 100644 index 0000000..4057a1f --- /dev/null +++ b/app/Services/Care/Ambulance/AmbulanceAnalyticsService.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, 'ambulance', $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'); + + $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'); + + $urgentOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'ambulance') + ->where('record_type', 'amb_scene') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(function (SpecialtyClinicalRecord $r) { + $acuity = (string) ($r->payload['acuity'] ?? ''); + + return in_array($acuity, ['Critical', 'Urgent'], true); + }) + ->count(); + + $dispatchRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'ambulance') + ->where('record_type', 'amb_dispatch') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $callNatureBreakdown = $dispatchRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['call_nature'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $nature) => [ + 'call_nature' => $nature, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $handoverRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'ambulance') + ->where('record_type', 'amb_handover') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $outcomeBreakdown = $handoverRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['outcome'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $outcome) => [ + 'outcome' => $outcome, + '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, 'ambulance')) + ->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, + 'urgent_open' => $urgentOpen, + 'call_nature_breakdown' => $callNatureBreakdown, + 'outcome_breakdown' => $outcomeBreakdown, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/Ambulance/AmbulanceWorkflowService.php b/app/Services/Care/Ambulance/AmbulanceWorkflowService.php new file mode 100644 index 0000000..c761912 --- /dev/null +++ b/app/Services/Care/Ambulance/AmbulanceWorkflowService.php @@ -0,0 +1,85 @@ + $payload + */ + public function stageFromDispatch(array $payload): string + { + if (! empty($payload['call_nature']) || ! empty($payload['location'])) { + return self::STAGE_DISPATCH; + } + + return self::STAGE_CHECK_IN; + } + + /** + * @param array $payload + */ + public function stageFromScene(array $payload): string + { + if (! empty($payload['scene_findings']) || ! empty($payload['chief_complaint'])) { + return self::STAGE_ON_SCENE; + } + + return self::STAGE_DISPATCH; + } + + /** + * @param array $payload + */ + public function stageFromEnRoute(array $payload): string + { + if (! empty($payload['interventions']) || ! empty($payload['destination'])) { + return self::STAGE_TRANSPORT; + } + + return self::STAGE_ON_SCENE; + } + + /** + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return str_contains($outcome, 'completed') + || str_contains($outcome, 'handed over') + || str_contains($outcome, 'cancelled') + || str_contains($outcome, 'refused'); + } + + /** + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_CHECK_IN => ['next' => self::STAGE_DISPATCH, 'label' => 'Start dispatch'], + self::STAGE_DISPATCH => ['next' => self::STAGE_ON_SCENE, 'label' => 'Move to on scene'], + self::STAGE_ON_SCENE => ['next' => self::STAGE_TRANSPORT, 'label' => 'Move to transport'], + self::STAGE_TRANSPORT => ['next' => self::STAGE_HANDOVER, 'label' => 'Move to handover'], + self::STAGE_HANDOVER => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'], + self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'], + ]; + } +} diff --git a/app/Services/Care/ChildWelfare/ChildWelfareAnalyticsService.php b/app/Services/Care/ChildWelfare/ChildWelfareAnalyticsService.php new file mode 100644 index 0000000..e985bd0 --- /dev/null +++ b/app/Services/Care/ChildWelfare/ChildWelfareAnalyticsService.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, 'child_welfare', $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'); + + $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'); + + $safeguardingOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'child_welfare') + ->where('record_type', 'cwc_assessment') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(function (SpecialtyClinicalRecord $r) { + $flag = strtolower((string) ($r->payload['safeguarding_flag'] ?? '')); + + return in_array($flag, ['concern', 'escalated'], true); + }) + ->count(); + + $assessmentRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'child_welfare') + ->where('record_type', 'cwc_assessment') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $growthBreakdown = $assessmentRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['growth_status'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $status) => [ + 'status' => $status, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $followupRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'child_welfare') + ->where('record_type', 'cwc_followup') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $outcomeBreakdown = $followupRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['outcome'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $outcome) => [ + 'outcome' => $outcome, + '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, 'child_welfare')) + ->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, + 'safeguarding_open' => $safeguardingOpen, + 'growth_breakdown' => $growthBreakdown, + 'outcome_breakdown' => $outcomeBreakdown, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/ChildWelfare/ChildWelfareWorkflowService.php b/app/Services/Care/ChildWelfare/ChildWelfareWorkflowService.php new file mode 100644 index 0000000..f4d1331 --- /dev/null +++ b/app/Services/Care/ChildWelfare/ChildWelfareWorkflowService.php @@ -0,0 +1,86 @@ + $payload + */ + public function stageFromIntake(array $payload): string + { + $history = trim((string) ($payload['history'] ?? '')); + if ($history !== '' || ! empty($payload['visit_reason'])) { + return self::STAGE_INTAKE; + } + + return self::STAGE_CHECK_IN; + } + + /** + * @param array $payload + */ + public function stageFromAssessment(array $payload): string + { + if (! empty($payload['exam_findings']) || ! empty($payload['assessment_summary'])) { + return self::STAGE_ASSESSMENT; + } + + return self::STAGE_INTAKE; + } + + /** + * @param array $payload + */ + public function stageFromPlan(array $payload): string + { + $goals = trim((string) ($payload['goals'] ?? '')); + if ($goals !== '') { + return self::STAGE_PLAN; + } + + return self::STAGE_ASSESSMENT; + } + + /** + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return str_contains($outcome, 'completed') + || str_contains($outcome, 'referred') + || str_contains($outcome, 'lost to follow-up'); + } + + /** + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_CHECK_IN => ['next' => self::STAGE_INTAKE, 'label' => 'Start intake'], + self::STAGE_INTAKE => ['next' => self::STAGE_ASSESSMENT, 'label' => 'Move to assessment'], + self::STAGE_ASSESSMENT => ['next' => self::STAGE_PLAN, 'label' => 'Move to care plan'], + self::STAGE_PLAN => ['next' => self::STAGE_FOLLOWUP, 'label' => 'Move to intervention / follow-up'], + self::STAGE_FOLLOWUP => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'], + self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'], + ]; + } +} diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index f3fd3a5..2e4c356 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -882,7 +882,8 @@ class DemoTenantSeeder 'dermatology' => ['Skin rash', 'Mole review', 'Dermatology procedure'], 'podiatry' => ['Diabetic foot care', 'Nail procedure', 'Foot pain'], 'fertility' => ['Fertility consult', 'Cycle review', 'IVF follow-up'], - 'child_welfare' => ['Growth monitoring', 'Immunization CWC', 'Well-baby visit'], + 'child_welfare' => ['Growth monitoring', 'Well-child CWC', 'Nutrition follow-up'], + 'ambulance' => ['Chest pain response', 'Trauma pickup', 'Facility transfer'], ]; $count = 0; @@ -1032,6 +1033,8 @@ class DemoTenantSeeder $this->seedDermatologyClinicalDemo($organization, $ownerRef); $this->seedPodiatryClinicalDemo($organization, $ownerRef); $this->seedFertilityClinicalDemo($organization, $ownerRef); + $this->seedChildWelfareClinicalDemo($organization, $ownerRef); + $this->seedAmbulanceClinicalDemo($organization, $ownerRef); return $count; } @@ -3010,6 +3013,251 @@ class DemoTenantSeeder } } + private function seedChildWelfareClinicalDemo(Organization $organization, string $ownerRef): void + { + $branchIds = Branch::owned($ownerRef) + ->where('organization_id', $organization->id) + ->pluck('id'); + + if ($branchIds->isEmpty()) { + return; + } + + $departmentIds = Department::owned($ownerRef) + ->whereIn('branch_id', $branchIds) + ->where('type', 'child_welfare') + ->where('is_active', true) + ->pluck('id'); + + if ($departmentIds->isEmpty()) { + return; + } + + $appointments = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('department_id', $departmentIds) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->whereNotNull('visit_id') + ->with('visit') + ->orderBy('branch_id') + ->orderBy('id') + ->get(); + + if ($appointments->isEmpty()) { + return; + } + + $clinical = app(SpecialtyClinicalRecordService::class); + $index = 0; + + foreach ($appointments as $appointment) { + $visit = $appointment->visit; + if (! $visit) { + continue; + } + + $concern = $index === 0; + $clinical->upsert( + $organization, + $visit, + 'child_welfare', + 'cwc_intake', + [ + 'visit_reason' => $concern ? 'Growth monitoring' : 'Routine well-child', + 'caregiver' => $concern ? 'Mother — Ama Mensah' : 'Father — Kojo Boateng', + 'history' => $concern + ? 'Weight faltering over 2 months; reduced appetite; breast milk + porridge' + : 'Well since last CWC; no illness; immunizations on track', + 'birth_history' => $concern ? 'Term SVD; birth weight 2.8 kg' : 'Term SVD; birth weight 3.2 kg', + 'immunization_status' => $concern ? 'Catch-up needed' : 'Up to date', + 'feeding' => $concern ? 'Breastfeeding + thin porridge twice daily' : 'Breastfeeding; complementary feeds started', + 'social_context' => $concern ? 'Single caregiver; food insecurity reported' : 'Stable household', + 'safeguarding_notes' => $concern ? 'Nutrition concern; monitor home situation — clinical record access only' : '', + 'allergies' => 'NKDA', + ], + $ownerRef, + $ownerRef, + 'intake', + ); + + $clinical->upsert( + $organization, + $visit, + 'child_welfare', + 'cwc_assessment', + [ + 'age_months' => $concern ? 18 : 9, + 'weight_kg' => $concern ? 8.1 : 8.6, + 'height_cm' => $concern ? 76 : 70, + 'head_circumference_cm' => $concern ? 45 : 44, + 'muac_cm' => $concern ? 11.2 : 13.5, + 'growth_status' => $concern ? 'Underweight' : 'Normal', + 'nutrition_risk' => $concern ? 'High' : 'Low', + 'development' => $concern ? 'Walks with support; 3–4 words' : 'Sits well; babbling', + 'exam_findings' => $concern + ? 'Thin; dry skin; no oedema; alert' + : 'Well nourished; soft fontanelle; clear chest', + 'safeguarding_flag' => $concern ? 'Concern' : 'None', + 'assessment_summary' => $concern + ? 'Underweight with high nutrition risk — counselling and close follow-up' + : 'Healthy well-child visit; continue routine CWC schedule', + ], + $ownerRef, + $ownerRef, + 'assessment', + ); + + if ($concern) { + $clinical->upsert( + $organization, + $visit, + 'child_welfare', + 'cwc_plan', + [ + 'goals' => 'Restore weight gain; improve dietary diversity; complete catch-up immunizations', + 'interventions' => 'Nutrition counselling; RUTF if eligible; growth recheck', + 'counseling' => 'Feeding frequency; hygiene; danger signs', + 'referrals' => 'Consider nutrition clinic if no weight gain in 2 weeks', + 'medications' => 'Vitamin A if due; deworming per schedule', + 'follow_up' => '2 weeks CWC', + 'advice' => 'Return sooner if fever, diarrhoea, or poor feeding', + ], + $ownerRef, + $ownerRef, + 'plan', + ); + } + + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $concern ? 'plan' : 'assessment']); + } + + $index++; + } + } + + private function seedAmbulanceClinicalDemo(Organization $organization, string $ownerRef): void + { + $branchIds = Branch::owned($ownerRef) + ->where('organization_id', $organization->id) + ->pluck('id'); + + if ($branchIds->isEmpty()) { + return; + } + + $departmentIds = Department::owned($ownerRef) + ->whereIn('branch_id', $branchIds) + ->where('type', 'ambulance') + ->where('is_active', true) + ->pluck('id'); + + if ($departmentIds->isEmpty()) { + return; + } + + $appointments = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('department_id', $departmentIds) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->whereNotNull('visit_id') + ->with('visit') + ->orderBy('branch_id') + ->orderBy('id') + ->get(); + + if ($appointments->isEmpty()) { + return; + } + + $clinical = app(SpecialtyClinicalRecordService::class); + $index = 0; + + foreach ($appointments as $appointment) { + $visit = $appointment->visit; + if (! $visit) { + continue; + } + + $critical = $index === 0; + $clinical->upsert( + $organization, + $visit, + 'ambulance', + 'amb_dispatch', + [ + 'call_nature' => $critical ? 'Medical emergency' : 'Transfer', + 'priority' => $critical ? 'Immediate' : 'Routine', + 'location' => $critical ? 'Kaneshie Market Road' : 'Ridge Hospital ward 3', + 'caller_info' => $critical ? 'Bystander — 024XXXXXXX' : 'Ward nurse', + 'crew' => $critical ? 'Paramedic + EMT' : 'EMT crew', + 'vehicle' => $critical ? 'AMB-01' : 'AMB-02', + 'dispatch_notes' => $critical + ? 'Chest pain, diaphoretic; request ALS response' + : 'Stable inter-facility transfer for imaging', + 'eta_minutes' => $critical ? 8 : 20, + ], + $ownerRef, + $ownerRef, + 'dispatch', + ); + + $clinical->upsert( + $organization, + $visit, + 'ambulance', + 'amb_scene', + [ + 'chief_complaint' => $critical ? 'Chest pain' : 'Inter-facility transfer', + 'acuity' => $critical ? 'Critical' : 'Stable', + 'mechanism' => $critical ? 'Sudden onset at rest' : 'Scheduled transfer', + 'airway_ok' => true, + 'breathing_ok' => $critical ? false : true, + 'circulation_ok' => $critical ? false : true, + 'gcs' => 15, + 'vitals_summary' => $critical + ? 'BP 88/54, HR 118, SpO2 91% on air, RR 28' + : 'BP 124/78, HR 82, SpO2 98%, RR 16', + 'scene_findings' => $critical + ? 'Pale, diaphoretic, ongoing central chest pain radiating to left arm' + : 'Patient comfortable on stretcher; IV in situ', + 'hazards' => $critical ? 'Busy roadside' : 'None', + ], + $ownerRef, + $ownerRef, + 'on_scene', + ); + + if ($critical) { + $clinical->upsert( + $organization, + $visit, + 'ambulance', + 'amb_en_route', + [ + 'destination' => 'Korle Bu Teaching Hospital ED', + 'interventions' => 'Oxygen via NRB; aspirin; IV access; cardiac monitoring', + 'medications' => 'Aspirin 300 mg PO', + 'response_to_treatment' => 'Pain partially eased; SpO2 improved to 95%', + 'monitoring' => 'Continuous ECG; vitals q5min', + 'deterioration' => false, + 'eta_facility' => '12 minutes', + 'notes' => 'Pre-alerted ED resus', + ], + $ownerRef, + $ownerRef, + 'transport', + ); + } + + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $critical ? 'transport' : 'on_scene']); + } + + $index++; + } + } + private function seedDentistryClinicalDemo(Organization $organization, string $ownerRef): void { // Specialty departments are provisioned without organization_id; scope via branches. diff --git a/app/Services/Care/SpecialtyClinicalRecordService.php b/app/Services/Care/SpecialtyClinicalRecordService.php index 1f56a6e..657d06e 100644 --- a/app/Services/Care/SpecialtyClinicalRecordService.php +++ b/app/Services/Care/SpecialtyClinicalRecordService.php @@ -430,6 +430,34 @@ class SpecialtyClinicalRecordService } } + if ($moduleKey === 'ambulance' && $recordType === 'amb_scene') { + $acuity = (string) ($payload['acuity'] ?? ''); + if (in_array($acuity, ['Critical', 'Urgent'], true)) { + $alerts[] = [ + 'code' => 'amb.acuity_high', + 'severity' => $acuity === 'Critical' ? 'critical' : 'warning', + 'message' => 'Scene acuity: '.$acuity.'.', + ]; + } + if (array_key_exists('circulation_ok', $payload) && in_array($payload['circulation_ok'], [false, 0, '0'], true)) { + $alerts[] = [ + 'code' => 'amb.circulation_unstable', + 'severity' => 'critical', + 'message' => 'Circulation marked unstable on scene.', + ]; + } + } + + if ($moduleKey === 'ambulance' && $recordType === 'amb_en_route') { + if (! empty($payload['deterioration'])) { + $alerts[] = [ + 'code' => 'amb.deterioration', + 'severity' => 'critical', + 'message' => 'Patient deterioration noted en route.', + ]; + } + } + if ($moduleKey === 'podiatry' && $recordType === 'foot_exam') { $risk = (string) ($payload['diabetes'] ?? ''); if (in_array($risk, ['High', 'Active ulcer'], true)) { @@ -633,6 +661,39 @@ class SpecialtyClinicalRecordService } } + if ($moduleKey === 'child_welfare' && $recordType === 'cwc_assessment') { + $flag = strtolower((string) ($payload['safeguarding_flag'] ?? '')); + if ($flag === 'escalated') { + $alerts[] = [ + 'code' => 'cwc.safeguarding', + 'severity' => 'critical', + 'message' => 'Safeguarding escalated — follow facility protocol.', + ]; + } elseif ($flag === 'concern') { + $alerts[] = [ + 'code' => 'cwc.safeguarding', + 'severity' => 'warning', + 'message' => 'Safeguarding concern recorded on CWC assessment.', + ]; + } + $nutrition = strtolower((string) ($payload['nutrition_risk'] ?? '')); + if ($nutrition === 'high') { + $alerts[] = [ + 'code' => 'cwc.nutrition_risk', + 'severity' => 'warning', + 'message' => 'High nutrition risk on child welfare assessment.', + ]; + } + $growth = strtolower((string) ($payload['growth_status'] ?? '')); + if (in_array($growth, ['wasted', 'underweight'], true)) { + $alerts[] = [ + 'code' => 'cwc.growth', + 'severity' => 'warning', + 'message' => 'Abnormal growth status: '.$payload['growth_status'].'.', + ]; + } + } + return $alerts; } diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php index 06ed6f4..422866e 100644 --- a/app/Services/Care/SpecialtyShellService.php +++ b/app/Services/Care/SpecialtyShellService.php @@ -29,6 +29,8 @@ use App\Services\Care\Infusion\InfusionWorkflowService; use App\Services\Care\Dermatology\DermatologyWorkflowService; use App\Services\Care\Podiatry\PodiatryWorkflowService; use App\Services\Care\Fertility\FertilityWorkflowService; +use App\Services\Care\ChildWelfare\ChildWelfareWorkflowService; +use App\Services\Care\Ambulance\AmbulanceWorkflowService; use Illuminate\Support\Collection; /** @@ -90,6 +92,8 @@ class SpecialtyShellService 'dermatology' => 'care.specialty.dermatology.stage', 'podiatry' => 'care.specialty.podiatry.stage', 'fertility' => 'care.specialty.fertility.stage', + 'child_welfare' => 'care.specialty.child-welfare.stage', + 'ambulance' => 'care.specialty.ambulance.stage', default => null, }; } @@ -122,6 +126,8 @@ class SpecialtyShellService 'dermatology' => app(DermatologyWorkflowService::class)->stageFlow(), 'podiatry' => app(PodiatryWorkflowService::class)->stageFlow(), 'fertility' => app(FertilityWorkflowService::class)->stageFlow(), + 'child_welfare' => app(ChildWelfareWorkflowService::class)->stageFlow(), + 'ambulance' => app(AmbulanceWorkflowService::class)->stageFlow(), 'dentistry' => [ 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], @@ -421,7 +427,7 @@ class SpecialtyShellService ) { $code = (string) ($stage['code'] ?? ''); - if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery', 'vaccination', 'pathology', 'infusion', 'dermatology', 'podiatry', 'fertility'], true) && $visitIds->isNotEmpty()) { + if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery', 'vaccination', 'pathology', 'infusion', 'dermatology', 'podiatry', 'fertility', 'child_welfare', 'ambulance'], true) && $visitIds->isNotEmpty()) { $count = Visit::owned($ownerRef) ->where('organization_id', $organization->id) ->whereIn('id', $visitIds) @@ -469,6 +475,8 @@ class SpecialtyShellService 'dermatology' => $clinical->countOpenByType($organization, 'dermatology', 'skin_exam', $branchScope), 'podiatry' => $clinical->countOpenByType($organization, 'podiatry', 'foot_exam', $branchScope), 'fertility' => $clinical->countOpenByType($organization, 'fertility', 'fert_exam', $branchScope), + 'child_welfare' => $clinical->countOpenByType($organization, 'child_welfare', 'cwc_assessment', $branchScope), + 'ambulance' => $clinical->countOpenByType($organization, 'ambulance', 'amb_scene', $branchScope), 'dentistry' => \App\Models\DentalPlanItem::query() ->whereHas('plan', function ($q) use ($organization, $ownerRef) { $q->owned($ownerRef) diff --git a/app/Services/Care/SpecialtyVisitStageService.php b/app/Services/Care/SpecialtyVisitStageService.php index 4d59c35..4fd1a09 100644 --- a/app/Services/Care/SpecialtyVisitStageService.php +++ b/app/Services/Care/SpecialtyVisitStageService.php @@ -192,6 +192,22 @@ class SpecialtyVisitStageService : ($stages[1] ?? ($stages[0] ?? null)); } + if ($moduleKey === 'child_welfare') { + $stages = $this->allowedStages($moduleKey); + + return in_array(\App\Services\Care\ChildWelfare\ChildWelfareWorkflowService::STAGE_INTAKE, $stages, true) + ? \App\Services\Care\ChildWelfare\ChildWelfareWorkflowService::STAGE_INTAKE + : ($stages[1] ?? ($stages[0] ?? null)); + } + + if ($moduleKey === 'ambulance') { + $stages = $this->allowedStages($moduleKey); + + return in_array(\App\Services\Care\Ambulance\AmbulanceWorkflowService::STAGE_DISPATCH, $stages, true) + ? \App\Services\Care\Ambulance\AmbulanceWorkflowService::STAGE_DISPATCH + : ($stages[1] ?? ($stages[0] ?? null)); + } + $stages = $this->allowedStages($moduleKey); foreach (['chair', 'in_care', 'exam', 'assessment', 'treatment'] as $preferred) { if (in_array($preferred, $stages, true)) { diff --git a/app/Support/DemoWorld.php b/app/Support/DemoWorld.php index 71b90cb..5dd8a57 100644 --- a/app/Support/DemoWorld.php +++ b/app/Support/DemoWorld.php @@ -214,6 +214,7 @@ final class DemoWorld 'podiatry' => 'Dr. Kofi Podiatry', 'fertility' => 'Dr. Abena Fertility', 'child_welfare' => 'Dr. Efua Child Welfare', + 'ambulance' => 'Dr. Kojo Ambulance', ]; public const STAFF = [ diff --git a/config/care.php b/config/care.php index 976d14b..51beb3f 100644 --- a/config/care.php +++ b/config/care.php @@ -43,6 +43,7 @@ return [ 'podiatry' => 'Podiatry', 'fertility' => 'Fertility', 'child_welfare' => 'Child Welfare Clinic', + 'ambulance' => 'Ambulance', ], /* @@ -78,6 +79,8 @@ return [ 'Podiatry', 'Fertility', 'Child Welfare Clinic', + 'Ambulance', + 'EMS / Paramedic', ], /* diff --git a/config/care_specialty_clinical.php b/config/care_specialty_clinical.php index b20220d..d5e0d34 100644 --- a/config/care_specialty_clinical.php +++ b/config/care_specialty_clinical.php @@ -148,8 +148,16 @@ return [ 'cycle' => 'fert_cycle', ], 'child_welfare' => [ - 'growth' => 'growth', - 'clinical_notes' => 'clinical_note', + 'intake' => 'cwc_intake', + 'assessment' => 'cwc_assessment', + 'plan' => 'cwc_plan', + 'followup' => 'cwc_followup', + ], + 'ambulance' => [ + 'dispatch' => 'amb_dispatch', + 'scene' => 'amb_scene', + 'en_route' => 'amb_en_route', + 'handover' => 'amb_handover', ], ], 'fields' => [ @@ -1021,22 +1029,89 @@ return [ ], ], 'child_welfare' => [ - 'growth' => [ + 'cwc_intake' => [ + ['name' => 'visit_reason', 'label' => 'Visit reason', 'type' => 'select', 'required' => true, 'options' => ['Routine well-child', 'Growth monitoring', 'Immunization review', 'Feeding concern', 'Development concern', 'Follow-up', 'Other']], + ['name' => 'caregiver', 'label' => 'Caregiver / accompanying adult', 'type' => 'text'], + ['name' => 'history', 'label' => 'Interval history', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'birth_history', 'label' => 'Birth / neonatal history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'immunization_status', 'label' => 'Immunization status', 'type' => 'select', 'options' => ['Up to date', 'Catch-up needed', 'Unknown', 'Deferred']], + ['name' => 'feeding', 'label' => 'Feeding / nutrition history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'social_context', 'label' => 'Social / family context', 'type' => 'textarea', 'rows' => 2], + ['name' => 'safeguarding_notes', 'label' => 'Sensitive / safeguarding notes', 'type' => 'textarea', 'rows' => 3], + ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'], + ], + 'cwc_assessment' => [ ['name' => 'age_months', 'label' => 'Age (months)', 'type' => 'number', 'required' => true], ['name' => 'weight_kg', 'label' => 'Weight (kg)', 'type' => 'number', 'required' => true], ['name' => 'height_cm', 'label' => 'Height / length (cm)', 'type' => 'number'], ['name' => 'head_circumference_cm', 'label' => 'Head circumference (cm)', 'type' => 'number'], ['name' => 'muac_cm', 'label' => 'MUAC (cm)', 'type' => 'number'], ['name' => 'growth_status', 'label' => 'Growth status', 'type' => 'select', 'options' => ['Normal', 'Underweight', 'Stunted', 'Wasted', 'Overweight']], - ['name' => 'feeding', 'label' => 'Feeding / nutrition', 'type' => 'textarea', 'rows' => 2], - ['name' => 'milestones', 'label' => 'Developmental milestones', 'type' => 'textarea', 'rows' => 2], - ['name' => 'counseling', 'label' => 'Counseling given', 'type' => 'textarea', 'rows' => 2], + ['name' => 'nutrition_risk', 'label' => 'Nutrition risk', 'type' => 'select', 'options' => ['Low', 'Moderate', 'High']], + ['name' => 'development', 'label' => 'Developmental milestones', 'type' => 'textarea', 'rows' => 2], + ['name' => 'exam_findings', 'label' => 'Exam findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'safeguarding_flag', 'label' => 'Safeguarding flag', 'type' => 'select', 'options' => ['None', 'Concern', 'Escalated']], + ['name' => 'assessment_summary', 'label' => 'Assessment summary', 'type' => 'textarea', 'required' => true, 'rows' => 3], ], - 'clinical_note' => [ - ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4], - ['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3], - ['name' => 'working_diagnosis', 'label' => 'Working diagnosis', 'type' => 'text'], - ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 3], + 'cwc_plan' => [ + ['name' => 'goals', 'label' => 'Care goals', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'interventions', 'label' => 'Planned interventions', 'type' => 'textarea', 'rows' => 2], + ['name' => 'counseling', 'label' => 'Counseling / education', 'type' => 'textarea', 'rows' => 2], + ['name' => 'referrals', 'label' => 'Referrals', 'type' => 'textarea', 'rows' => 2], + ['name' => 'medications', 'label' => 'Medications / supplements', 'type' => 'textarea', 'rows' => 2], + ['name' => 'follow_up', 'label' => 'Follow-up', 'type' => 'text'], + ['name' => 'advice', 'label' => 'Caregiver advice', 'type' => 'textarea', 'rows' => 2], + ], + 'cwc_followup' => [ + ['name' => 'review_type', 'label' => 'Review type', 'type' => 'select', 'required' => true, 'options' => ['Clinic follow-up', 'Growth recheck', 'Intervention review', 'Caregiver counselling', 'Discharge from CWC episode']], + ['name' => 'interventions_delivered', 'label' => 'Interventions delivered', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'progress', 'label' => 'Progress since last visit', 'type' => 'textarea', 'rows' => 2], + ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['In progress', 'Completed — continue CWC', 'Completed uneventfully', 'Referred', 'Lost to follow-up']], + ['name' => 'next_visit', 'label' => 'Next visit', 'type' => 'text'], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], + ], + ], + 'ambulance' => [ + 'amb_dispatch' => [ + ['name' => 'call_nature', 'label' => 'Call nature', 'type' => 'select', 'required' => true, 'options' => ['Medical emergency', 'Trauma / accident', 'Transfer', 'Obstetric', 'Psychiatric', 'Standby', 'Other']], + ['name' => 'priority', 'label' => 'Priority', 'type' => 'select', 'required' => true, 'options' => ['Immediate', 'Urgent', 'Routine', 'Scheduled transfer']], + ['name' => 'location', 'label' => 'Scene / pickup location', 'type' => 'text', 'required' => true], + ['name' => 'caller_info', 'label' => 'Caller / contact', 'type' => 'text'], + ['name' => 'crew', 'label' => 'Crew assigned', 'type' => 'text'], + ['name' => 'vehicle', 'label' => 'Vehicle / unit', 'type' => 'text'], + ['name' => 'dispatch_notes', 'label' => 'Dispatch notes', 'type' => 'textarea', 'rows' => 3], + ['name' => 'eta_minutes', 'label' => 'ETA (minutes)', 'type' => 'number'], + ], + 'amb_scene' => [ + ['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true], + ['name' => 'acuity', 'label' => 'Scene acuity', 'type' => 'select', 'required' => true, 'options' => ['Critical', 'Urgent', 'Stable', 'Non-urgent']], + ['name' => 'mechanism', 'label' => 'Mechanism / history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'airway_ok', 'label' => 'Airway patent', 'type' => 'boolean'], + ['name' => 'breathing_ok', 'label' => 'Breathing adequate', 'type' => 'boolean'], + ['name' => 'circulation_ok', 'label' => 'Circulation stable', 'type' => 'boolean'], + ['name' => 'gcs', 'label' => 'GCS', 'type' => 'number'], + ['name' => 'vitals_summary', 'label' => 'Vitals summary', 'type' => 'textarea', 'rows' => 2], + ['name' => 'scene_findings', 'label' => 'Scene findings / triage', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'hazards', 'label' => 'Scene hazards', 'type' => 'textarea', 'rows' => 2], + ], + 'amb_en_route' => [ + ['name' => 'destination', 'label' => 'Destination facility', 'type' => 'text', 'required' => true], + ['name' => 'interventions', 'label' => 'Interventions en route', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'medications', 'label' => 'Medications given', 'type' => 'textarea', 'rows' => 2], + ['name' => 'response_to_treatment', 'label' => 'Response to treatment', 'type' => 'textarea', 'rows' => 2], + ['name' => 'monitoring', 'label' => 'Monitoring notes', 'type' => 'textarea', 'rows' => 2], + ['name' => 'deterioration', 'label' => 'Deterioration en route', 'type' => 'boolean'], + ['name' => 'eta_facility', 'label' => 'ETA to facility', 'type' => 'text'], + ['name' => 'notes', 'label' => 'Transport notes', 'type' => 'textarea', 'rows' => 2], + ], + 'amb_handover' => [ + ['name' => 'receiving_facility', 'label' => 'Receiving facility / unit', 'type' => 'text', 'required' => true], + ['name' => 'receiving_clinician', 'label' => 'Receiving clinician', 'type' => 'text'], + ['name' => 'handover_summary', 'label' => 'Handover summary (SBAR)', 'type' => 'textarea', 'required' => true, 'rows' => 4], + ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed — handed over', 'Completed uneventfully', 'Transferred to another unit', 'Patient refused transport', 'Cancelled on scene']], + ['name' => 'time_critical', 'label' => 'Time-critical pathway', 'type' => 'select', 'options' => ['None', 'Stroke', 'STEMI', 'Trauma', 'Obstetric', 'Other']], + ['name' => 'follow_up', 'label' => 'Follow-up / documentation', 'type' => 'text'], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], ], ], ], diff --git a/config/care_specialty_icons.php b/config/care_specialty_icons.php index f001423..ec05f89 100644 --- a/config/care_specialty_icons.php +++ b/config/care_specialty_icons.php @@ -30,5 +30,6 @@ return [ 'finger-print' => '', 'sparkles' => '', 'gift' => '', + 'truck' => '', 'squares-2x2' => '', ]; diff --git a/config/care_specialty_modules.php b/config/care_specialty_modules.php index f04ccde..b926d1e 100644 --- a/config/care_specialty_modules.php +++ b/config/care_specialty_modules.php @@ -166,7 +166,8 @@ return [ 'queue_keywords' => ['pedia', 'paedia', 'child', 'infant'], 'access' => 'general', 'roles' => ['doctor', 'nurse', 'receptionist'], - 'specialist_keywords' => ['pedia', 'paedia', 'pediatric', 'paediatric', 'child', 'infant'], + // Avoid bare "child" — collides with Child Welfare Clinic specialty labels. + 'specialist_keywords' => ['pedia', 'paedia', 'pediatric', 'paediatric', 'infant'], ], 'orthopedics' => [ 'label' => 'Orthopedics', @@ -348,15 +349,17 @@ return [ ], 'child_welfare' => [ 'label' => 'Child Welfare Clinic', - 'description' => 'Well-child visits, growth monitoring, and CWC queues.', + 'description' => 'Well-child / CWC clinics: intake, growth assessment, care plans, and follow-up.', 'department_type' => 'child_welfare', 'department_name' => 'Child Welfare Clinic', 'queue_name' => 'Child Welfare', 'queue_prefix' => 'CWC', 'nav_label' => 'Child Welfare', 'icon' => 'gift', - 'queue_keywords' => ['cwc', 'child welfare', 'well baby', 'growth'], + 'queue_keywords' => ['cwc', 'child welfare', 'well baby', 'well-child', 'welfare clinic'], 'access' => 'general', 'roles' => ['doctor', 'nurse', 'receptionist'], + // Prefer multi-word / CWC stems — do not use bare "child" (steals pediatrics). + 'specialist_keywords' => ['child welfare', 'cwc', 'welfare clinic', 'well baby', 'well-child'], ], ]; diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index 9ba14a8..558a587 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -836,21 +836,71 @@ return [ ], 'child_welfare' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], - ['code' => 'growth', 'label' => 'Growth / wellness', 'queue_point' => 'chair'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'intake', 'label' => 'Intake / history', 'queue_point' => 'waiting'], + ['code' => 'assessment', 'label' => 'Assessment', 'queue_point' => 'chair'], + ['code' => 'plan', 'label' => 'Care plan', 'queue_point' => 'chair'], + ['code' => 'followup', 'label' => 'Intervention / follow-up', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ - ['code' => 'cwc.visit', 'label' => 'Well-child visit', 'amount_minor' => 3000, 'type' => 'consultation'], + ['code' => 'cwc.visit', 'label' => 'Well-child / CWC visit', 'amount_minor' => 3000, 'type' => 'consultation'], + ['code' => 'cwc.growth', 'label' => 'Growth & development assessment', 'amount_minor' => 2500, 'type' => 'consultation'], + ['code' => 'cwc.counseling', 'label' => 'Caregiver counselling', 'amount_minor' => 2000, 'type' => 'consultation'], + ['code' => 'cwc.followup', 'label' => 'CWC follow-up review', 'amount_minor' => 2500, 'type' => 'consultation'], ], 'workspace_tabs' => [ 'overview' => 'Overview', - 'growth' => 'Growth monitoring', - 'clinical_notes' => 'Clinical notes', + 'intake' => 'Intake / history', + 'assessment' => 'Assessment', + 'plan' => 'Care plan', + 'followup' => 'Intervention / follow-up', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'intake' => 'intake', + 'assessment' => 'assessment', + 'plan' => 'plan', + 'followup' => 'followup', + 'completed' => 'overview', + ], + ], + 'ambulance' => [ + 'stages' => [ + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'dispatch', 'label' => 'Dispatch', 'queue_point' => 'waiting'], + ['code' => 'on_scene', 'label' => 'On scene', 'queue_point' => 'chair'], + ['code' => 'transport', 'label' => 'Transport', 'queue_point' => 'chair'], + ['code' => 'handover', 'label' => 'Handover', 'queue_point' => 'chair'], + ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], + ], + 'services' => [ + ['code' => 'amb.dispatch', 'label' => 'Ambulance dispatch / response', 'amount_minor' => 8000, 'type' => 'consultation'], + ['code' => 'amb.scene', 'label' => 'Scene assessment', 'amount_minor' => 5000, 'type' => 'consultation'], + ['code' => 'amb.transport', 'label' => 'Patient transport', 'amount_minor' => 12000, 'type' => 'procedure'], + ['code' => 'amb.handover', 'label' => 'Facility handover', 'amount_minor' => 4000, 'type' => 'consultation'], + ], + 'workspace_tabs' => [ + 'overview' => 'Overview', + 'dispatch' => 'Dispatch', + 'scene' => 'Scene / triage', + 'en_route' => 'En-route care', + 'handover' => 'Handover', + 'orders' => 'Orders', + 'billing' => 'Billing', + 'documents' => 'Documents', + ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'dispatch' => 'dispatch', + 'on_scene' => 'scene', + 'transport' => 'en_route', + 'handover' => 'handover', + 'completed' => 'overview', + ], ], ], diff --git a/resources/views/care/specialty/ambulance/print.blade.php b/resources/views/care/specialty/ambulance/print.blade.php new file mode 100644 index 0000000..1d06236 --- /dev/null +++ b/resources/views/care/specialty/ambulance/print.blade.php @@ -0,0 +1,82 @@ + + + + + Ambulance 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') ?? '—' }} +

+ +

Dispatch

+ @if ($dispatch) +
+
Call nature
{{ $dispatch->payload['call_nature'] ?? '—' }}
+
Priority
{{ $dispatch->payload['priority'] ?? '—' }}
+
Location
{{ $dispatch->payload['location'] ?? '—' }}
+
Crew
{{ $dispatch->payload['crew'] ?? '—' }}
+
Vehicle
{{ $dispatch->payload['vehicle'] ?? '—' }}
+
Notes
{{ $dispatch->payload['dispatch_notes'] ?? '—' }}
+
+ @else +

No dispatch recorded.

+ @endif + +

Scene / triage

+ @if ($scene) +
+
Complaint
{{ $scene->payload['chief_complaint'] ?? '—' }}
+
Acuity
{{ $scene->payload['acuity'] ?? '—' }}
+
Findings
{{ $scene->payload['scene_findings'] ?? '—' }}
+
Vitals
{{ $scene->payload['vitals_summary'] ?? '—' }}
+
GCS
{{ $scene->payload['gcs'] ?? '—' }}
+
+ @else +

No scene assessment recorded.

+ @endif + +

En-route care

+ @if ($enRoute) +
+
Destination
{{ $enRoute->payload['destination'] ?? '—' }}
+
Interventions
{{ $enRoute->payload['interventions'] ?? '—' }}
+
Medications
{{ $enRoute->payload['medications'] ?? '—' }}
+
Response
{{ $enRoute->payload['response_to_treatment'] ?? '—' }}
+
+ @else +

No en-route care recorded.

+ @endif + +

Handover

+ @if ($handover) +
+
Facility
{{ $handover->payload['receiving_facility'] ?? '—' }}
+
Clinician
{{ $handover->payload['receiving_clinician'] ?? '—' }}
+
Summary
{{ $handover->payload['handover_summary'] ?? '—' }}
+
Outcome
{{ $handover->payload['outcome'] ?? '—' }}
+
Pathway
{{ $handover->payload['time_critical'] ?? '—' }}
+
+ @else +

No handover recorded.

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

Ambulance reports

+

Arrivals, urgent open runs, call nature, handover outcomes, length of stay, and ambulance revenue.

+
+ ← Specialty home +
+ +
+
+ + +
+
+ + +
+ +
+ +
+
+

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

Urgent open

+

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

+
+
+

Avg LOS (range)

+

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

+
+
+ +
+
+

Call nature

+
    + @forelse ($report['call_nature_breakdown'] as $row) +
  • + {{ $row['call_nature'] }} + {{ $row['total'] }} +
  • + @empty +
  • No dispatches in range.
  • + @endforelse +
+
+ +
+

Handover outcomes

+
    + @forelse ($report['outcome_breakdown'] as $row) +
  • + {{ $row['outcome'] }} + {{ $row['total'] }} +
  • + @empty +
  • No handovers in range.
  • + @endforelse +
+
+
+ +
+

Ambulance service revenue

+
    + @forelse ($report['revenue_by_service'] as $row) +
  • + {{ $row->description }} ×{{ $row->total }} + {{ $currency }} {{ number_format($row->amount_minor / 100, 2) }} +
  • + @empty +
  • No billed ambulance services in range.
  • + @endforelse +
+
+
+
diff --git a/resources/views/care/specialty/ambulance/workspace-handover.blade.php b/resources/views/care/specialty/ambulance/workspace-handover.blade.php new file mode 100644 index 0000000..639f1a2 --- /dev/null +++ b/resources/views/care/specialty/ambulance/workspace-handover.blade.php @@ -0,0 +1,70 @@ +@php + $record = $ambulanceHandover; + $payload = $record?->payload ?? []; + $canManage = $canManageClinical ?? $canConsult ?? false; +@endphp + +
+
+
+

Facility handover

+

Completed handovers can close the ambulance visit.

+
+ Print summary +
+ + @if ($canManage) +
+ @csrf + +
+ @foreach ($clinicalFields ?? [] as $field) + @php + $name = $field['name']; + $value = old('payload.'.$name, $payload[$name] ?? ''); + $type = $field['type'] ?? 'text'; + @endphp +
in_array($type, ['textarea'], true)])> + + @if ($type === 'textarea') + + @elseif ($type === 'select') + + @elseif ($type === 'boolean') + + @else + + @endif +
+ @endforeach +
+ +
+ @elseif ($record) +
+ @foreach ($clinicalFields ?? [] as $field) +
+
{{ $field['label'] }}
+
{{ $payload[$field['name']] ?? '—' }}
+
+ @endforeach +
+ @else +

No handover recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/ambulance/workspace-overview.blade.php b/resources/views/care/specialty/ambulance/workspace-overview.blade.php new file mode 100644 index 0000000..1d1eb08 --- /dev/null +++ b/resources/views/care/specialty/ambulance/workspace-overview.blade.php @@ -0,0 +1,72 @@ +@php + $dispatch = $ambulanceDispatch?->payload ?? []; + $scene = $ambulanceScene?->payload ?? []; + $enRoute = $ambulanceEnRoute?->payload ?? []; + $handover = $ambulanceHandover?->payload ?? []; +@endphp + +
+
+
+

Ambulance overview

+

Dispatch, scene triage, en-route care, and facility handover for this run.

+
+ +
+ +
+
+

Call

+

{{ $dispatch['call_nature'] ?? '—' }}

+

Priority {{ $dispatch['priority'] ?? '—' }}

+
+
+

Scene

+

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

+

Acuity {{ $scene['acuity'] ?? '—' }}

+
+
+

Destination / handover

+

{{ $enRoute['destination'] ?? ($handover['receiving_facility'] ?? 'Not set') }}

+

{{ $handover['outcome'] ?? '—' }}

+
+
+ +
+
+
Location
+
{{ $dispatch['location'] ?? '—' }}
+
+
+
En-route interventions
+
{{ \Illuminate\Support\Str::limit($enRoute['interventions'] ?? '—', 80) }}
+
+
+
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/child-welfare/print.blade.php b/resources/views/care/specialty/child-welfare/print.blade.php new file mode 100644 index 0000000..8cb353f --- /dev/null +++ b/resources/views/care/specialty/child-welfare/print.blade.php @@ -0,0 +1,83 @@ + + + + + Child Welfare 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') ?? '—' }} +

+ +

Intake / history

+ @if ($intake) +
+
Visit reason
{{ $intake->payload['visit_reason'] ?? '—' }}
+
Caregiver
{{ $intake->payload['caregiver'] ?? '—' }}
+
History
{{ $intake->payload['history'] ?? '—' }}
+
Immunization
{{ $intake->payload['immunization_status'] ?? '—' }}
+
Feeding
{{ $intake->payload['feeding'] ?? '—' }}
+
Safeguarding
{{ $intake->payload['safeguarding_notes'] ?? '—' }}
+
+ @else +

No intake recorded.

+ @endif + +

Assessment

+ @if ($assessment) +
+
Age (months)
{{ $assessment->payload['age_months'] ?? '—' }}
+
Weight (kg)
{{ $assessment->payload['weight_kg'] ?? '—' }}
+
Growth status
{{ $assessment->payload['growth_status'] ?? '—' }}
+
Nutrition risk
{{ $assessment->payload['nutrition_risk'] ?? '—' }}
+
Safeguarding
{{ $assessment->payload['safeguarding_flag'] ?? '—' }}
+
Exam
{{ $assessment->payload['exam_findings'] ?? '—' }}
+
Summary
{{ $assessment->payload['assessment_summary'] ?? '—' }}
+
+ @else +

No assessment recorded.

+ @endif + +

Care plan

+ @if ($plan) +
+
Goals
{{ $plan->payload['goals'] ?? '—' }}
+
Interventions
{{ $plan->payload['interventions'] ?? '—' }}
+
Counseling
{{ $plan->payload['counseling'] ?? '—' }}
+
Follow-up
{{ $plan->payload['follow_up'] ?? '—' }}
+
+ @else +

No care plan recorded.

+ @endif + +

Intervention / follow-up

+ @if ($followup) +
+
Review type
{{ $followup->payload['review_type'] ?? '—' }}
+
Interventions
{{ $followup->payload['interventions_delivered'] ?? '—' }}
+
Outcome
{{ $followup->payload['outcome'] ?? '—' }}
+
Next visit
{{ $followup->payload['next_visit'] ?? '—' }}
+
+ @else +

No follow-up recorded.

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

Child Welfare reports

+

Arrivals, safeguarding flags, growth status, follow-up outcomes, length of stay, and CWC revenue.

+
+ ← Specialty home +
+ +
+
+ + +
+
+ + +
+ +
+ +
+
+

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

Safeguarding open

+

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

+
+
+

Avg LOS (range)

+

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

+
+
+ +
+
+

Growth status

+
    + @forelse ($report['growth_breakdown'] as $row) +
  • + {{ $row['status'] }} + {{ $row['total'] }} +
  • + @empty +
  • No assessments in range.
  • + @endforelse +
+
+ +
+

Follow-up outcomes

+
    + @forelse ($report['outcome_breakdown'] as $row) +
  • + {{ $row['outcome'] }} + {{ $row['total'] }} +
  • + @empty +
  • No follow-ups in range.
  • + @endforelse +
+
+
+ +
+

Child Welfare service revenue

+
    + @forelse ($report['revenue_by_service'] as $row) +
  • + {{ $row->description }} ×{{ $row->total }} + {{ $currency }} {{ number_format($row->amount_minor / 100, 2) }} +
  • + @empty +
  • No billed child welfare services in range.
  • + @endforelse +
+
+
+
diff --git a/resources/views/care/specialty/child-welfare/workspace-followup.blade.php b/resources/views/care/specialty/child-welfare/workspace-followup.blade.php new file mode 100644 index 0000000..504efb5 --- /dev/null +++ b/resources/views/care/specialty/child-welfare/workspace-followup.blade.php @@ -0,0 +1,70 @@ +@php + $record = $childWelfareFollowup; + $payload = $record?->payload ?? []; + $canManage = $canManageClinical ?? $canConsult ?? false; +@endphp + +
+
+
+

Intervention / follow-up

+

Completed outcomes can close the child welfare visit.

+
+ Print summary +
+ + @if ($canManage) +
+ @csrf + +
+ @foreach ($clinicalFields ?? [] as $field) + @php + $name = $field['name']; + $value = old('payload.'.$name, $payload[$name] ?? ''); + $type = $field['type'] ?? 'text'; + @endphp +
in_array($type, ['textarea'], true)])> + + @if ($type === 'textarea') + + @elseif ($type === 'select') + + @elseif ($type === 'boolean') + + @else + + @endif +
+ @endforeach +
+ +
+ @elseif ($record) +
+ @foreach ($clinicalFields ?? [] as $field) +
+
{{ $field['label'] }}
+
{{ $payload[$field['name']] ?? '—' }}
+
+ @endforeach +
+ @else +

No intervention / follow-up recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/child-welfare/workspace-overview.blade.php b/resources/views/care/specialty/child-welfare/workspace-overview.blade.php new file mode 100644 index 0000000..6abfe12 --- /dev/null +++ b/resources/views/care/specialty/child-welfare/workspace-overview.blade.php @@ -0,0 +1,78 @@ +@php + $intake = $childWelfareIntake?->payload ?? []; + $assessment = $childWelfareAssessment?->payload ?? []; + $plan = $childWelfarePlan?->payload ?? []; +@endphp + +
+
+
+

Child Welfare overview

+

Intake, assessment, care plan, and follow-up for this CWC episode. Sensitive notes use the same clinical-record access controls as other specialty modules.

+
+ +
+ +
+
+

Visit reason

+

{{ $intake['visit_reason'] ?? '—' }}

+

{{ $intake['caregiver'] ?? '—' }}

+
+
+

Growth / assessment

+

{{ $assessment['growth_status'] ?? 'Not assessed' }}

+

+ @if (! empty($assessment['weight_kg'])) + {{ $assessment['weight_kg'] }} kg + @if (! empty($assessment['age_months'])) · {{ $assessment['age_months'] }} mo @endif + @else + — + @endif +

+
+
+

Care plan

+

{{ \Illuminate\Support\Str::limit($plan['goals'] ?? 'Not set', 40) }}

+

{{ $plan['follow_up'] ?? '—' }}

+
+
+ +
+
+
Interval history
+
{{ $intake['history'] ?? '—' }}
+
+
+
Assessment summary
+
{{ $assessment['assessment_summary'] ?? '—' }}
+
+
+
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/partials/actions-menu.blade.php b/resources/views/care/specialty/partials/actions-menu.blade.php index c5e4e48..cc78de5 100644 --- a/resources/views/care/specialty/partials/actions-menu.blade.php +++ b/resources/views/care/specialty/partials/actions-menu.blade.php @@ -56,6 +56,8 @@ 'dermatology' => 'check_in', 'podiatry' => 'check_in', 'fertility' => 'check_in', + 'child_welfare' => 'check_in', + 'ambulance' => 'check_in', default => collect($stages ?? [])->pluck('code')->first(), }; $defaultStartLabel = match ($moduleKey) { @@ -74,6 +76,8 @@ 'dermatology' => 'Start check-in', 'podiatry' => 'Start check-in', 'fertility' => 'Start check-in', + 'child_welfare' => 'Start check-in', + 'ambulance' => 'Start check-in', default => 'Start', }; $currentStage = $workspaceVisit?->specialty_stage; diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index 6b38aba..4fcf6b9 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -97,6 +97,10 @@ @include('care.specialty.podiatry.workspace-'.$activeTab) @elseif ($moduleKey === 'fertility' && in_array($activeTab, ['overview', 'cycle'], true)) @include('care.specialty.fertility.workspace-'.$activeTab) + @elseif ($moduleKey === 'child_welfare' && in_array($activeTab, ['overview', 'followup'], true)) + @include('care.specialty.child-welfare.workspace-'.$activeTab) + @elseif ($moduleKey === 'ambulance' && in_array($activeTab, ['overview', 'handover'], true)) + @include('care.specialty.ambulance.workspace-'.$activeTab) @elseif ($activeTab === 'billing')

diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index 3a4be60..0de2ae0 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -88,6 +88,12 @@ @if ($moduleKey === 'fertility') Reports @endif + @if ($moduleKey === 'child_welfare') + Reports + @endif + @if ($moduleKey === 'ambulance') + Reports + @endif History @if ($canManageClinical ?? $canManageSpecialty ?? true) Billing diff --git a/routes/web.php b/routes/web.php index d5adb3a..2d32fc8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -59,6 +59,8 @@ use App\Http\Controllers\Care\InfusionWorkspaceController; use App\Http\Controllers\Care\DermatologyWorkspaceController; use App\Http\Controllers\Care\PodiatryWorkspaceController; use App\Http\Controllers\Care\FertilityWorkspaceController; +use App\Http\Controllers\Care\ChildWelfareWorkspaceController; +use App\Http\Controllers\Care\AmbulanceWorkspaceController; use App\Http\Controllers\Care\SpecialtyModuleController; use App\Http\Controllers\Care\VisitWorkflowController; use App\Http\Controllers\NotificationController; @@ -159,6 +161,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/specialty/dermatology/reports', [DermatologyWorkspaceController::class, 'reports'])->name('care.specialty.dermatology.reports'); Route::get('/specialty/podiatry/reports', [PodiatryWorkspaceController::class, 'reports'])->name('care.specialty.podiatry.reports'); Route::get('/specialty/fertility/reports', [FertilityWorkspaceController::class, 'reports'])->name('care.specialty.fertility.reports'); + Route::get('/specialty/child-welfare/reports', [ChildWelfareWorkspaceController::class, 'reports'])->name('care.specialty.child-welfare.reports'); + Route::get('/specialty/ambulance/reports', [AmbulanceWorkspaceController::class, 'reports'])->name('care.specialty.ambulance.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'); @@ -246,6 +250,12 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/specialty/fertility/workspace/{visit}/stage', [FertilityWorkspaceController::class, 'setStage'])->name('care.specialty.fertility.stage'); Route::post('/specialty/fertility/workspace/{visit}/cycle', [FertilityWorkspaceController::class, 'saveCycle'])->name('care.specialty.fertility.cycle'); Route::get('/specialty/fertility/workspace/{visit}/print', [FertilityWorkspaceController::class, 'printSummary'])->name('care.specialty.fertility.print'); + Route::post('/specialty/child-welfare/workspace/{visit}/stage', [ChildWelfareWorkspaceController::class, 'setStage'])->name('care.specialty.child-welfare.stage'); + Route::post('/specialty/child-welfare/workspace/{visit}/followup', [ChildWelfareWorkspaceController::class, 'saveFollowUp'])->name('care.specialty.child-welfare.followup'); + Route::get('/specialty/child-welfare/workspace/{visit}/print', [ChildWelfareWorkspaceController::class, 'printSummary'])->name('care.specialty.child-welfare.print'); + Route::post('/specialty/ambulance/workspace/{visit}/stage', [AmbulanceWorkspaceController::class, 'setStage'])->name('care.specialty.ambulance.stage'); + Route::post('/specialty/ambulance/workspace/{visit}/handover', [AmbulanceWorkspaceController::class, 'saveHandover'])->name('care.specialty.ambulance.handover'); + Route::get('/specialty/ambulance/workspace/{visit}/print', [AmbulanceWorkspaceController::class, 'printSummary'])->name('care.specialty.ambulance.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'); Route::post('/specialty/{module}/refer', [SpecialtyModuleController::class, 'refer'])->name('care.specialty.refer'); diff --git a/tests/Feature/CareAmbulanceSuiteTest.php b/tests/Feature/CareAmbulanceSuiteTest.php new file mode 100644 index 0000000..2cf8558 --- /dev/null +++ b/tests/Feature/CareAmbulanceSuiteTest.php @@ -0,0 +1,243 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'amb-owner', + 'name' => 'Owner', + 'email' => 'amb-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Ambulance Clinic', + 'slug' => 'ambulance-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, + 'ambulance', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'ambulance') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-AMB-SUITE', + 'first_name' => 'Kojo', + 'last_name' => 'Mensah', + 'gender' => 'male', + 'date_of_birth' => '1985-03-20', + ]); + + $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' => 'check_in', + ]); + + 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_scene_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'ambulance', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Ambulance overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Scene / triage') + ->assertSee('Start dispatch') + ->assertSee('Ambulance reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'ambulance', + 'visit' => $this->visit, + 'tab' => 'scene', + ])) + ->assertOk() + ->assertSee('Chief complaint') + ->assertSee('Scene findings'); + } + + public function test_scene_sets_stage_and_acuity_alert(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'ambulance', + 'visit' => $this->visit, + ]), [ + 'tab' => 'scene', + 'payload' => [ + 'chief_complaint' => 'Chest pain', + 'acuity' => 'Critical', + 'mechanism' => 'Sudden onset at home', + 'airway_ok' => '1', + 'breathing_ok' => '1', + 'circulation_ok' => '0', + 'gcs' => '15', + 'vitals_summary' => 'BP 90/60, HR 120, SpO2 92%', + 'scene_findings' => 'Diaphoretic, pale, ongoing chest pain', + 'hazards' => 'None', + ], + ]) + ->assertRedirect(); + + $this->assertSame('on_scene', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'amb_scene') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('amb.acuity_high', $codes); + } + + public function test_stage_advance_and_handover_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.ambulance.stage', $this->visit), [ + 'stage' => 'handover', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'ambulance', + 'visit' => $this->visit, + 'tab' => 'handover', + ])); + + $this->assertSame('handover', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.ambulance.handover', $this->visit), [ + 'tab' => 'handover', + 'payload' => [ + 'receiving_facility' => 'Korle Bu ED', + 'receiving_clinician' => 'Dr. Boateng', + 'handover_summary' => 'S: Chest pain. B: Sudden onset. A: Critical acuity. R: Handed over to ED resus.', + 'outcome' => 'Completed — handed over', + 'time_critical' => 'STEMI', + 'follow_up' => 'PCR filed', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'ambulance', + 'visit' => $this->visit, + 'tab' => 'handover', + ])); + + $this->assertSame('completed', $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' => 'amb_handover', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.ambulance.reports')) + ->assertOk() + ->assertSee('Ambulance reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.ambulance.print', $this->visit)) + ->assertOk() + ->assertSee('Ambulance summary') + ->assertSee($this->patient->fullName()); + } + + public function test_list_index_does_not_auto_open_patient(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.show', 'ambulance')) + ->assertOk() + ->assertDontSee('Ambulance overview'); + } +} diff --git a/tests/Feature/CareChildWelfareSuiteTest.php b/tests/Feature/CareChildWelfareSuiteTest.php new file mode 100644 index 0000000..4d63ed4 --- /dev/null +++ b/tests/Feature/CareChildWelfareSuiteTest.php @@ -0,0 +1,241 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'cwc-owner', + 'name' => 'Owner', + 'email' => 'cwc-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Child Welfare Clinic', + 'slug' => 'child-welfare-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, + 'child_welfare', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'child_welfare') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-CWC-SUITE', + 'first_name' => 'Kwame', + 'last_name' => 'Mensah', + 'gender' => 'male', + 'date_of_birth' => '2024-01-15', + ]); + + $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' => 'check_in', + ]); + + 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_assessment_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'child_welfare', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Child Welfare overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Start intake') + ->assertSee('Child Welfare reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'child_welfare', + 'visit' => $this->visit, + 'tab' => 'assessment', + ])) + ->assertOk() + ->assertSee('Age (months)') + ->assertSee('Growth status') + ->assertSee('Safeguarding flag'); + } + + public function test_assessment_sets_stage_and_safeguarding_alert(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'child_welfare', + 'visit' => $this->visit, + ]), [ + 'tab' => 'assessment', + 'payload' => [ + 'age_months' => 18, + 'weight_kg' => 8.2, + 'height_cm' => 76, + 'growth_status' => 'Underweight', + 'nutrition_risk' => 'High', + 'exam_findings' => 'Thin; dry skin; alert', + 'safeguarding_flag' => 'Concern', + 'assessment_summary' => 'Underweight with nutrition risk — counselling and follow-up', + ], + ]) + ->assertRedirect(); + + $this->assertSame('assessment', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'cwc_assessment') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('cwc.safeguarding', $codes); + $this->assertContains('cwc.nutrition_risk', $codes); + } + + public function test_stage_advance_and_followup_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.child-welfare.stage', $this->visit), [ + 'stage' => 'followup', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'child_welfare', + 'visit' => $this->visit, + 'tab' => 'followup', + ])); + + $this->assertSame('followup', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.child-welfare.followup', $this->visit), [ + 'tab' => 'followup', + 'payload' => [ + 'review_type' => 'Growth recheck', + 'interventions_delivered' => 'Nutrition counselling; MUAC recheck', + 'progress' => 'Weight improving', + 'outcome' => 'Completed uneventfully', + 'next_visit' => '4 weeks', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'child_welfare', + 'visit' => $this->visit, + 'tab' => 'followup', + ])); + + $this->assertSame('completed', $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' => 'cwc_followup', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.child-welfare.reports')) + ->assertOk() + ->assertSee('Child Welfare reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.child-welfare.print', $this->visit)) + ->assertOk() + ->assertSee('Child Welfare summary') + ->assertSee($this->patient->fullName()); + } + + public function test_list_index_does_not_auto_open_patient(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.show', 'child_welfare')) + ->assertOk() + ->assertDontSee('Child Welfare overview'); + } +} diff --git a/tests/Feature/CareSpecialtyModulesTest.php b/tests/Feature/CareSpecialtyModulesTest.php index e77b913..a8d7249 100644 --- a/tests/Feature/CareSpecialtyModulesTest.php +++ b/tests/Feature/CareSpecialtyModulesTest.php @@ -387,7 +387,7 @@ class CareSpecialtyModulesTest extends TestCase foreach ([ 'emergency', 'blood_bank', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery', 'vaccination', 'pathology', 'infusion', - 'dermatology', 'podiatry', 'fertility', 'child_welfare', + 'dermatology', 'podiatry', 'fertility', 'child_welfare', 'ambulance', ] as $key) { $this->assertArrayHasKey($key, $catalog); }