diff --git a/app/Http/Controllers/Care/MaternityWorkspaceController.php b/app/Http/Controllers/Care/MaternityWorkspaceController.php new file mode 100644 index 0000000..bb97e98 --- /dev/null +++ b/app/Http/Controllers/Care/MaternityWorkspaceController.php @@ -0,0 +1,216 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'maternity'), 403); + } + + protected function assertMaternityManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'maternity'), 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->assertMaternityManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'maternity', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'maternity', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('maternity', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function savePostnatal( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + MaternityWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertMaternityManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('maternity', 'postnatal_note') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'postnatal']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('maternity', 'postnatal_note'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'maternity', + 'postnatal_note', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + MaternityWorkflowService::STAGE_POSTNATAL, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'maternity', + MaternityWorkflowService::STAGE_POSTNATAL, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'maternity', + MaternityWorkflowService::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' => 'maternity', 'visit' => $visit, 'tab' => 'postnatal']) + ->with('success', 'Delivery / postnatal note saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + MaternityAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertMaternityAccess($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.maternity.reports', [ + 'moduleKey' => 'maternity', + 'definition' => $modules->definition('maternity'), + 'shellNav' => $shell->navItems('maternity'), + '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->assertMaternityAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.maternity.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'history' => $clinical->findForVisit($visit, 'maternity', 'anc_history'), + 'exam' => $clinical->findForVisit($visit, 'maternity', 'obstetric_exam'), + 'fetal' => $clinical->findForVisit($visit, 'maternity', 'fetal_notes'), + 'investigation' => $clinical->findForVisit($visit, 'maternity', 'mat_investigation'), + 'plan' => $clinical->findForVisit($visit, 'maternity', 'mat_plan'), + 'postnatal' => $clinical->findForVisit($visit, 'maternity', 'postnatal_note'), + ]); + } +} diff --git a/app/Http/Controllers/Care/PhysiotherapyWorkspaceController.php b/app/Http/Controllers/Care/PhysiotherapyWorkspaceController.php new file mode 100644 index 0000000..abefe89 --- /dev/null +++ b/app/Http/Controllers/Care/PhysiotherapyWorkspaceController.php @@ -0,0 +1,208 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'physiotherapy'), 403); + } + + protected function assertPhysiotherapyManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'physiotherapy'), 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->assertPhysiotherapyManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'physiotherapy', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'physiotherapy', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('physiotherapy', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveSession( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + PhysiotherapyWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertPhysiotherapyManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('physiotherapy', 'pt_session') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'session']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('physiotherapy', 'pt_session'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'physiotherapy', + 'pt_session', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + PhysiotherapyWorkflowService::STAGE_SESSION, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + $suggested = $workflow->stageFromSession($record->payload ?? []); + try { + $stages->setStage($organization, $visit, 'physiotherapy', $suggested, $owner, $owner); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'physiotherapy', + PhysiotherapyWorkflowService::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' => 'physiotherapy', 'visit' => $visit, 'tab' => 'session']) + ->with('success', 'Therapy session saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + PhysiotherapyAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertPhysiotherapyAccess($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.physiotherapy.reports', [ + 'moduleKey' => 'physiotherapy', + 'definition' => $modules->definition('physiotherapy'), + 'shellNav' => $shell->navItems('physiotherapy'), + '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->assertPhysiotherapyAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.physiotherapy.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'assessment' => $clinical->findForVisit($visit, 'physiotherapy', 'pt_assessment'), + 'plan' => $clinical->findForVisit($visit, 'physiotherapy', 'pt_plan'), + 'session' => $clinical->findForVisit($visit, 'physiotherapy', 'pt_session'), + 'homeProgram' => $clinical->findForVisit($visit, 'physiotherapy', 'pt_home_program'), + ]); + } +} diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 9aecfb9..29aa813 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -154,6 +154,8 @@ class SpecialtyModuleController extends Controller 'emergency' => 'triage', 'blood_bank' => 'requests', 'ophthalmology' => 'refraction', + 'physiotherapy' => 'assessment', + 'maternity' => 'history', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -226,6 +228,8 @@ class SpecialtyModuleController extends Controller 'emergency' => 'triage', 'blood_bank' => 'requests', 'ophthalmology' => 'refraction', + 'physiotherapy' => 'assessment', + 'maternity' => 'history', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -409,6 +413,103 @@ class SpecialtyModuleController extends Controller } } + + if ($module === 'physiotherapy' && in_array($recordType, ['pt_assessment', 'pt_plan', 'pt_session', 'pt_home_program'], true)) { + $workflow = app(\App\Services\Care\Physiotherapy\PhysiotherapyWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'pt_assessment' => $workflow->stageFromAssessment($record->payload ?? []), + 'pt_plan' => $workflow->stageFromPlan($record->payload ?? []), + 'pt_session' => $workflow->stageFromSession($record->payload ?? []), + 'pt_home_program' => $workflow->stageFromHomeProgram($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'assessment' => 1, + 'treatment_plan' => 2, + 'session' => 3, + 'progress' => 4, + 'completed' => 5, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage( + $organization, + $visit, + 'physiotherapy', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage( + $organization, + $visit, + 'physiotherapy', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } + } + + if ($module === 'maternity' && in_array($recordType, ['anc_history', 'obstetric_exam', 'fetal_notes', 'mat_investigation', 'mat_plan'], true)) { + $workflow = app(\App\Services\Care\Maternity\MaternityWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'anc_history' => $workflow->stageFromHistory($record->payload ?? []), + 'obstetric_exam' => $workflow->stageFromExam($record->payload ?? []), + 'fetal_notes' => $workflow->stageFromFetal($record->payload ?? []), + 'mat_investigation' => \App\Services\Care\Maternity\MaternityWorkflowService::STAGE_INVESTIGATION, + 'mat_plan' => $workflow->stageFromPlan($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'history' => 1, + 'exam' => 2, + 'investigation' => 3, + 'plan' => 4, + 'postnatal' => 5, + 'completed' => 6, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage( + $organization, + $visit, + 'maternity', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage( + $organization, + $visit, + 'maternity', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } + } + return redirect() ->route('care.specialty.workspace', [ 'module' => $module, @@ -809,6 +910,20 @@ class SpecialtyModuleController extends Controller $ophthalmologyProcedure = null; $ophthalmologyStageCodes = []; $ophthalmologyStageFlow = []; + $physiotherapyAssessment = null; + $physiotherapyPlan = null; + $physiotherapySession = null; + $physiotherapyHomeProgram = null; + $physiotherapyStageCodes = []; + $physiotherapyStageFlow = []; + $maternityHistory = null; + $maternityExam = null; + $maternityFetal = null; + $maternityInvestigation = null; + $maternityPlan = null; + $maternityPostnatal = null; + $maternityStageCodes = []; + $maternityStageFlow = []; $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); $clinical = app(SpecialtyClinicalRecordService::class); @@ -925,6 +1040,26 @@ class SpecialtyModuleController extends Controller $ophthalmologyStageFlow = app(\App\Services\Care\Ophthalmology\OphthalmologyWorkflowService::class)->stageFlow(); } + if ($module === 'physiotherapy') { + $physiotherapyAssessment = $clinical->findForVisit($workspaceVisit, 'physiotherapy', 'pt_assessment'); + $physiotherapyPlan = $clinical->findForVisit($workspaceVisit, 'physiotherapy', 'pt_plan'); + $physiotherapySession = $clinical->findForVisit($workspaceVisit, 'physiotherapy', 'pt_session'); + $physiotherapyHomeProgram = $clinical->findForVisit($workspaceVisit, 'physiotherapy', 'pt_home_program'); + $physiotherapyStageCodes = collect($shell->stages('physiotherapy'))->pluck('code')->all(); + $physiotherapyStageFlow = app(\App\Services\Care\Physiotherapy\PhysiotherapyWorkflowService::class)->stageFlow(); + } + + if ($module === 'maternity') { + $maternityHistory = $clinical->findForVisit($workspaceVisit, 'maternity', 'anc_history'); + $maternityExam = $clinical->findForVisit($workspaceVisit, 'maternity', 'obstetric_exam'); + $maternityFetal = $clinical->findForVisit($workspaceVisit, 'maternity', 'fetal_notes'); + $maternityInvestigation = $clinical->findForVisit($workspaceVisit, 'maternity', 'mat_investigation'); + $maternityPlan = $clinical->findForVisit($workspaceVisit, 'maternity', 'mat_plan'); + $maternityPostnatal = $clinical->findForVisit($workspaceVisit, 'maternity', 'postnatal_note'); + $maternityStageCodes = collect($shell->stages('maternity'))->pluck('code')->all(); + $maternityStageFlow = app(\App\Services\Care\Maternity\MaternityWorkflowService::class)->stageFlow(); + } + if ($patientHeader !== null) { $patientHeader['clinical_alerts'] = $clinicalAlerts; } @@ -1019,6 +1154,20 @@ class SpecialtyModuleController extends Controller 'ophthalmologyProcedure' => $ophthalmologyProcedure, 'ophthalmologyStageCodes' => $ophthalmologyStageCodes, 'ophthalmologyStageFlow' => $ophthalmologyStageFlow, + 'physiotherapyAssessment' => $physiotherapyAssessment, + 'physiotherapyPlan' => $physiotherapyPlan, + 'physiotherapySession' => $physiotherapySession, + 'physiotherapyHomeProgram' => $physiotherapyHomeProgram, + 'physiotherapyStageCodes' => $physiotherapyStageCodes, + 'physiotherapyStageFlow' => $physiotherapyStageFlow, + 'maternityHistory' => $maternityHistory, + 'maternityExam' => $maternityExam, + 'maternityFetal' => $maternityFetal, + 'maternityInvestigation' => $maternityInvestigation, + 'maternityPlan' => $maternityPlan, + 'maternityPostnatal' => $maternityPostnatal, + 'maternityStageCodes' => $maternityStageCodes, + 'maternityStageFlow' => $maternityStageFlow, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'branchLabel' => $branchLabel, diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 6e0b49c..b366272 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -1015,6 +1015,8 @@ class DemoTenantSeeder $this->seedDentistryClinicalDemo($organization, $ownerRef); $this->seedBloodBankInventoryDemo($organization, $ownerRef); $this->seedOphthalmologyClinicalDemo($organization, $ownerRef); + $this->seedPhysiotherapyClinicalDemo($organization, $ownerRef); + $this->seedMaternityClinicalDemo($organization, $ownerRef); return $count; } @@ -1242,6 +1244,269 @@ class DemoTenantSeeder } } + + /** + * Seed assessment + plan (+ optional session) on open physiotherapy visits. + */ + private function seedPhysiotherapyClinicalDemo(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', 'physiotherapy') + ->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; + } + + $highPain = $index === 0; + $clinical->upsert( + $organization, + $visit, + 'physiotherapy', + 'pt_assessment', + [ + 'chief_complaint' => $highPain ? 'Severe low back pain after lifting' : 'Knee stiffness post-injury', + 'onset' => $highPain ? 'Acute — 3 days ago' : 'Subacute — 3 weeks', + 'pain_score' => $highPain ? 9 : 4, + 'body_region' => $highPain ? 'Back / lumbar' : 'Knee', + 'rom' => $highPain ? 'Lumbar flexion limited, guarded' : 'Knee flexion 90°, extension -5°', + 'strength' => $highPain ? 'Core activation poor' : 'Quadriceps 4/5', + 'special_tests' => $highPain ? 'SLR positive right' : 'McMurray negative', + 'red_flags' => $highPain ? 'Night pain; no saddle anaesthesia' : '', + 'goals' => $highPain ? 'Reduce pain and restore safe lifting' : 'Restore full ROM and return to sport', + ], + $ownerRef, + $ownerRef, + 'assessment', + ); + + $clinical->upsert( + $organization, + $visit, + 'physiotherapy', + 'pt_plan', + [ + 'diagnosis' => $highPain ? 'Acute lumbar strain' : 'Post-traumatic knee stiffness', + 'frequency' => '2–3× / week', + 'duration_weeks' => $highPain ? 4 : 6, + 'interventions' => $highPain + ? 'Manual therapy, core activation, graded activity' + : 'ROM drills, strengthening, proprioception', + 'precautions' => $highPain ? 'Avoid heavy flexion under load' : 'No pivoting until week 4', + 'goals' => 'Pain <3/10 and functional mobility', + 'outcome_measures' => 'VAS, Oswestry / LEFS', + ], + $ownerRef, + $ownerRef, + 'treatment_plan', + ); + + if ($highPain) { + $clinical->upsert( + $organization, + $visit, + 'physiotherapy', + 'pt_session', + [ + 'session_number' => 1, + 'modalities' => 'Heat, soft-tissue mobilisation', + 'exercises' => 'Pelvic tilts, bird-dog, walking', + 'pain_before' => 9, + 'pain_after' => 6, + 'response' => 'Tolerated well; pain eased post-session', + 'outcome' => 'Completed — continue plan', + 'next_review' => '2 days', + 'notes' => 'Demo physio session.', + ], + $ownerRef, + $ownerRef, + 'session', + ); + } + + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $highPain ? 'session' : 'treatment_plan']); + } + + $index++; + } + } + + /** + * Seed ANC history + exam (+ optional plan) on open maternity visits. + */ + private function seedMaternityClinicalDemo(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', 'maternity') + ->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; + } + + $highRisk = $index === 0; + $clinical->upsert( + $organization, + $visit, + 'maternity', + 'anc_history', + [ + 'gravida' => $highRisk ? 3 : 1, + 'para' => $highRisk ? 1 : 0, + 'lmp' => '12 Jan 2026', + 'edd' => '19 Oct 2026', + 'gestational_age_weeks' => $highRisk ? 34 : 22, + 'anc_visit_number' => $highRisk ? 6 : 3, + 'obstetric_history' => $highRisk ? 'Previous CS; one miscarriage' : 'Primigravida', + 'medical_history' => $highRisk ? 'Hypertension in pregnancy' : 'None significant', + 'allergies' => 'None known', + 'medications' => 'IFAS, calcium', + 'social_history' => 'Lives with partner; support available', + ], + $ownerRef, + $ownerRef, + 'history', + ); + + $clinical->upsert( + $organization, + $visit, + 'maternity', + 'obstetric_exam', + [ + 'bp' => $highRisk ? '158/102' : '118/74', + 'pulse' => $highRisk ? 92 : 78, + 'weight_kg' => 72, + 'fundal_height' => $highRisk ? 33 : 22, + 'presentation' => 'Cephalic', + 'lie' => 'Longitudinal', + 'engagement' => $highRisk ? '3/5' : 'Free', + 'oedema' => $highRisk ? 'Moderate' : 'None', + 'urine' => $highRisk ? 'Protein +1' : 'NAD', + 'danger_signs' => $highRisk ? 'Headache; visual spots' : '', + 'findings' => $highRisk + ? 'Hypertension with oedema — escalate high-risk ANC' + : 'Normal ANC examination', + ], + $ownerRef, + $ownerRef, + 'exam', + ); + + $clinical->upsert( + $organization, + $visit, + 'maternity', + 'fetal_notes', + [ + 'fetal_heart_rate' => $highRisk ? 168 : 142, + 'fetal_movements' => $highRisk ? 'Reduced' : 'Normal', + 'presentation' => 'Cephalic', + 'liquor' => 'Adequate clinically', + 'ctg_summary' => $highRisk ? 'Baseline raised; observe' : 'Not indicated', + 'notes' => 'Demo fetal notes for maternity suite.', + ], + $ownerRef, + $ownerRef, + 'exam', + ); + + if ($highRisk) { + $clinical->upsert( + $organization, + $visit, + 'maternity', + 'mat_plan', + [ + 'risk_category' => 'High risk', + 'plan' => 'Urgent senior review; BP control; fetal monitoring; consider admission.', + 'medications' => 'Continue antihypertensives as prescribed', + 'education' => 'Return immediately if headache, bleeding, or reduced movements', + 'postnatal_needed' => false, + 'follow_up' => 'Same day review', + 'referral' => 'Obstetric high-risk clinic', + ], + $ownerRef, + $ownerRef, + 'plan', + ); + } + + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']); + } + + $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/Maternity/MaternityAnalyticsService.php b/app/Services/Care/Maternity/MaternityAnalyticsService.php new file mode 100644 index 0000000..712bfcc --- /dev/null +++ b/app/Services/Care/Maternity/MaternityAnalyticsService.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, 'maternity', $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'); + + $highRiskOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'maternity') + ->where('record_type', 'mat_plan') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(function (SpecialtyClinicalRecord $r) { + $risk = strtolower((string) ($r->payload['risk_category'] ?? '')); + + return str_contains($risk, 'high'); + }) + ->count(); + + $planRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'maternity') + ->where('record_type', 'mat_plan') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $riskBreakdown = $planRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['risk_category'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $risk) => [ + 'risk' => $risk, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $postnatalRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'maternity') + ->where('record_type', 'postnatal_note') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $postnatalOutcomes = $postnatalRecords + ->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, 'maternity')) + ->pluck('label') + ->filter() + ->values() + ->all(); + + $revenueByService = BillLineItem::query() + ->where('owner_ref', $ownerRef) + ->where('source_type', 'specialty_service') + ->whereIn('description', $serviceLabels ?: ['__none__']) + ->whereBetween('created_at', [$rangeFrom, $rangeTo]) + ->whereHas('bill', function ($q) use ($organization, $visitIds) { + $q->where('organization_id', $organization->id) + ->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds) + ->whereNotIn('status', [Bill::STATUS_VOID]); + }) + ->selectRaw('description, count(*) as total, sum(total_minor) as amount_minor') + ->groupBy('description') + ->orderByDesc('amount_minor') + ->get(); + + return [ + 'arrivals_today' => $arrivalsToday, + 'completed_today' => $completedToday, + 'high_risk_open' => $highRiskOpen, + 'risk_breakdown' => $riskBreakdown, + 'postnatal_outcomes' => $postnatalOutcomes, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/Maternity/MaternityWorkflowService.php b/app/Services/Care/Maternity/MaternityWorkflowService.php new file mode 100644 index 0000000..16c65f5 --- /dev/null +++ b/app/Services/Care/Maternity/MaternityWorkflowService.php @@ -0,0 +1,106 @@ + $payload + */ + public function stageFromHistory(array $payload): string + { + if (isset($payload['gravida']) || isset($payload['gestational_age_weeks'])) { + return self::STAGE_HISTORY; + } + + return self::STAGE_CHECK_IN; + } + + /** + * @param array $payload + */ + public function stageFromExam(array $payload): string + { + $findings = trim((string) ($payload['findings'] ?? '')); + $danger = strtolower((string) ($payload['danger_signs'] ?? '')); + + if ($findings !== '' || $danger !== '') { + return self::STAGE_EXAM; + } + + return self::STAGE_HISTORY; + } + + /** + * @param array $payload + */ + public function stageFromFetal(array $payload): string + { + if (isset($payload['fetal_heart_rate']) && $payload['fetal_heart_rate'] !== '') { + return self::STAGE_EXAM; + } + + return self::STAGE_EXAM; + } + + /** + * @param array $payload + */ + public function stageFromPlan(array $payload): string + { + if (! empty($payload['postnatal_needed'])) { + return self::STAGE_POSTNATAL; + } + + $plan = trim((string) ($payload['plan'] ?? '')); + if ($plan !== '') { + return self::STAGE_PLAN; + } + + return self::STAGE_INVESTIGATION; + } + + /** + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return str_contains($outcome, 'discharged') + || str_contains($outcome, 'continue anc'); + } + + /** + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_CHECK_IN => ['next' => self::STAGE_HISTORY, 'label' => 'Start ANC history'], + self::STAGE_HISTORY => ['next' => self::STAGE_EXAM, 'label' => 'Move to exam / vitals'], + self::STAGE_EXAM => ['next' => self::STAGE_INVESTIGATION, 'label' => 'Move to investigations'], + self::STAGE_INVESTIGATION => ['next' => self::STAGE_PLAN, 'label' => 'Move to care plan'], + self::STAGE_PLAN => ['next' => self::STAGE_POSTNATAL, 'label' => 'Move to delivery / postnatal'], + self::STAGE_POSTNATAL => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'], + self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'], + ]; + } +} diff --git a/app/Services/Care/Physiotherapy/PhysiotherapyAnalyticsService.php b/app/Services/Care/Physiotherapy/PhysiotherapyAnalyticsService.php new file mode 100644 index 0000000..dab8216 --- /dev/null +++ b/app/Services/Care/Physiotherapy/PhysiotherapyAnalyticsService.php @@ -0,0 +1,164 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'physiotherapy', $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'); + + $highPainOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'physiotherapy') + ->where('record_type', 'pt_assessment') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(fn (SpecialtyClinicalRecord $r) => (int) ($r->payload['pain_score'] ?? 0) >= 8) + ->count(); + + $planRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'physiotherapy') + ->where('record_type', 'pt_plan') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $diagnosisBreakdown = $planRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['diagnosis'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $diagnosis) => [ + 'diagnosis' => $diagnosis, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $sessionRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'physiotherapy') + ->where('record_type', 'pt_session') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $sessionOutcomes = $sessionRecords + ->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, 'physiotherapy')) + ->pluck('label') + ->filter() + ->values() + ->all(); + + $revenueByService = BillLineItem::query() + ->where('owner_ref', $ownerRef) + ->where('source_type', 'specialty_service') + ->whereIn('description', $serviceLabels ?: ['__none__']) + ->whereBetween('created_at', [$rangeFrom, $rangeTo]) + ->whereHas('bill', function ($q) use ($organization, $visitIds) { + $q->where('organization_id', $organization->id) + ->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds) + ->whereNotIn('status', [Bill::STATUS_VOID]); + }) + ->selectRaw('description, count(*) as total, sum(total_minor) as amount_minor') + ->groupBy('description') + ->orderByDesc('amount_minor') + ->get(); + + return [ + 'arrivals_today' => $arrivalsToday, + 'completed_today' => $completedToday, + 'high_pain_open' => $highPainOpen, + 'diagnosis_breakdown' => $diagnosisBreakdown, + 'session_outcomes' => $sessionOutcomes, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/Physiotherapy/PhysiotherapyWorkflowService.php b/app/Services/Care/Physiotherapy/PhysiotherapyWorkflowService.php new file mode 100644 index 0000000..e163ec8 --- /dev/null +++ b/app/Services/Care/Physiotherapy/PhysiotherapyWorkflowService.php @@ -0,0 +1,113 @@ + $payload + */ + public function stageFromAssessment(array $payload): string + { + $complaint = trim((string) ($payload['chief_complaint'] ?? '')); + $goals = trim((string) ($payload['goals'] ?? '')); + + if ($complaint !== '' || $goals !== '') { + return self::STAGE_ASSESSMENT; + } + + return self::STAGE_CHECK_IN; + } + + /** + * @param array $payload + */ + public function stageFromPlan(array $payload): string + { + $diagnosis = trim((string) ($payload['diagnosis'] ?? '')); + $interventions = trim((string) ($payload['interventions'] ?? '')); + + if ($diagnosis !== '' || $interventions !== '') { + return self::STAGE_TREATMENT_PLAN; + } + + return self::STAGE_ASSESSMENT; + } + + /** + * @param array $payload + */ + public function stageFromSession(array $payload): string + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + if (str_contains($outcome, 'progress review') || str_contains($outcome, 'discharge')) { + return self::STAGE_PROGRESS; + } + + if (str_contains($outcome, 'completed') || trim((string) ($payload['modalities'] ?? '')) !== '') { + return self::STAGE_SESSION; + } + + return self::STAGE_TREATMENT_PLAN; + } + + /** + * @param array $payload + */ + public function stageFromHomeProgram(array $payload): string + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + if (str_contains($outcome, 'discharge') || str_contains($outcome, 'goals met')) { + return self::STAGE_PROGRESS; + } + + if (trim((string) ($payload['exercises'] ?? '')) !== '' + || trim((string) ($payload['progress_notes'] ?? '')) !== '') { + return self::STAGE_PROGRESS; + } + + return self::STAGE_SESSION; + } + + /** + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return str_contains($outcome, 'discharge'); + } + + /** + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_CHECK_IN => ['next' => self::STAGE_ASSESSMENT, 'label' => 'Start assessment'], + self::STAGE_ASSESSMENT => ['next' => self::STAGE_TREATMENT_PLAN, 'label' => 'Move to treatment plan'], + self::STAGE_TREATMENT_PLAN => ['next' => self::STAGE_SESSION, 'label' => 'Move to therapy session'], + self::STAGE_SESSION => ['next' => self::STAGE_PROGRESS, 'label' => 'Move to progress review'], + self::STAGE_PROGRESS => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'], + self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'], + ]; + } +} diff --git a/app/Services/Care/SpecialtyClinicalRecordService.php b/app/Services/Care/SpecialtyClinicalRecordService.php index 6efcf09..0dcf703 100644 --- a/app/Services/Care/SpecialtyClinicalRecordService.php +++ b/app/Services/Care/SpecialtyClinicalRecordService.php @@ -295,6 +295,84 @@ class SpecialtyClinicalRecordService } } + if ($moduleKey === 'physiotherapy' && $recordType === 'pt_assessment') { + $pain = (int) ($payload['pain_score'] ?? 0); + if ($pain >= 8) { + $alerts[] = [ + 'code' => 'pt.severe_pain', + 'severity' => 'warning', + 'message' => 'Severe pain score (≥8) on physio assessment.', + ]; + } + $red = trim((string) ($payload['red_flags'] ?? '')); + if ($red !== '') { + $alerts[] = [ + 'code' => 'pt.red_flags', + 'severity' => 'critical', + 'message' => 'Physio red flags recorded — review urgently.', + ]; + } + } + + if ($moduleKey === 'maternity' && $recordType === 'obstetric_exam') { + $danger = trim((string) ($payload['danger_signs'] ?? '')); + if ($danger !== '') { + $alerts[] = [ + 'code' => 'mat.danger_signs', + 'severity' => 'critical', + 'message' => 'Maternity danger signs flagged.', + ]; + } + $bp = (string) ($payload['bp'] ?? ''); + if (preg_match('/(\d{2,3})\s*[\/]\s*(\d{2,3})/', $bp, $m)) { + $sys = (int) $m[1]; + $dia = (int) $m[2]; + if ($sys >= 160 || $dia >= 110) { + $alerts[] = [ + 'code' => 'mat.severe_hypertension', + 'severity' => 'critical', + 'message' => 'Severe hypertension on obstetric exam.', + ]; + } elseif ($sys >= 140 || $dia >= 90) { + $alerts[] = [ + 'code' => 'mat.hypertension', + 'severity' => 'warning', + 'message' => 'Elevated BP on obstetric exam.', + ]; + } + } + } + + if ($moduleKey === 'maternity' && $recordType === 'fetal_notes') { + $fhr = isset($payload['fetal_heart_rate']) ? (int) $payload['fetal_heart_rate'] : null; + if ($fhr !== null && ($fhr < 110 || $fhr > 160)) { + $alerts[] = [ + 'code' => 'mat.abnormal_fhr', + 'severity' => 'critical', + 'message' => 'Fetal heart rate outside 110–160 bpm.', + ]; + } + $movements = strtolower((string) ($payload['fetal_movements'] ?? '')); + if (str_contains($movements, 'reduced') || str_contains($movements, 'absent')) { + $alerts[] = [ + 'code' => 'mat.reduced_movements', + 'severity' => 'critical', + 'message' => 'Reduced or absent fetal movements.', + ]; + } + } + + if ($moduleKey === 'maternity' && $recordType === 'mat_plan') { + $risk = strtolower((string) ($payload['risk_category'] ?? '')); + if (str_contains($risk, 'high')) { + $alerts[] = [ + 'code' => 'mat.high_risk', + 'severity' => 'warning', + 'message' => 'High-risk maternity care plan.', + ]; + } + } + return $alerts; } diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php index 84a435e..e6b4a53 100644 --- a/app/Services/Care/SpecialtyShellService.php +++ b/app/Services/Care/SpecialtyShellService.php @@ -12,6 +12,8 @@ use App\Models\Visit; use App\Services\Care\BloodBank\BloodBankWorkflowService; use App\Services\Care\Emergency\EmergencyWorkflowService; use App\Services\Care\Ophthalmology\OphthalmologyWorkflowService; +use App\Services\Care\Physiotherapy\PhysiotherapyWorkflowService; +use App\Services\Care\Maternity\MaternityWorkflowService; use Illuminate\Support\Collection; /** @@ -56,6 +58,8 @@ class SpecialtyShellService 'emergency' => 'care.specialty.emergency.stage', 'blood_bank' => 'care.specialty.blood-bank.stage', 'ophthalmology' => 'care.specialty.ophthalmology.stage', + 'physiotherapy' => 'care.specialty.physiotherapy.stage', + 'maternity' => 'care.specialty.maternity.stage', default => null, }; } @@ -71,6 +75,8 @@ class SpecialtyShellService 'emergency' => app(EmergencyWorkflowService::class)->stageFlow(), 'blood_bank' => app(BloodBankWorkflowService::class)->stageFlow(), 'ophthalmology' => app(OphthalmologyWorkflowService::class)->stageFlow(), + 'physiotherapy' => app(PhysiotherapyWorkflowService::class)->stageFlow(), + 'maternity' => app(MaternityWorkflowService::class)->stageFlow(), 'dentistry' => [ 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], @@ -370,7 +376,7 @@ class SpecialtyShellService ) { $code = (string) ($stage['code'] ?? ''); - if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology'], true) && $visitIds->isNotEmpty()) { + if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity'], true) && $visitIds->isNotEmpty()) { $count = Visit::owned($ownerRef) ->where('organization_id', $organization->id) ->whereIn('id', $visitIds) @@ -401,6 +407,8 @@ class SpecialtyShellService 'emergency' => $clinical->countOpenByType($organization, 'emergency', 'triage', $branchScope), 'blood_bank' => $clinical->countOpenByType($organization, 'blood_bank', 'blood_request', $branchScope), 'ophthalmology' => $clinical->countOpenByType($organization, 'ophthalmology', 'eye_exam', $branchScope), + 'physiotherapy' => $clinical->countOpenByType($organization, 'physiotherapy', 'pt_assessment', $branchScope), + 'maternity' => $clinical->countOpenByType($organization, 'maternity', 'anc_history', $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 a358a0c..3492c2a 100644 --- a/app/Services/Care/SpecialtyVisitStageService.php +++ b/app/Services/Care/SpecialtyVisitStageService.php @@ -104,6 +104,22 @@ class SpecialtyVisitStageService : ($stages[1] ?? ($stages[0] ?? null)); } + if ($moduleKey === 'physiotherapy') { + $stages = $this->allowedStages($moduleKey); + + return in_array(\App\Services\Care\Physiotherapy\PhysiotherapyWorkflowService::STAGE_ASSESSMENT, $stages, true) + ? \App\Services\Care\Physiotherapy\PhysiotherapyWorkflowService::STAGE_ASSESSMENT + : ($stages[1] ?? ($stages[0] ?? null)); + } + + if ($moduleKey === 'maternity') { + $stages = $this->allowedStages($moduleKey); + + return in_array(\App\Services\Care\Maternity\MaternityWorkflowService::STAGE_HISTORY, $stages, true) + ? \App\Services\Care\Maternity\MaternityWorkflowService::STAGE_HISTORY + : ($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/config/care.php b/config/care.php index bd1e7ef..976d14b 100644 --- a/config/care.php +++ b/config/care.php @@ -283,6 +283,8 @@ return [ 'specialty_blood_bank' => 'Blood bank document', 'specialty_dentistry' => 'Dental document', 'specialty_ophthalmology' => 'Eye care document', + 'specialty_physiotherapy' => 'Physiotherapy document', + 'specialty_maternity' => 'Maternity document', 'other' => 'Other', ], diff --git a/config/care_specialty_clinical.php b/config/care_specialty_clinical.php index 746083e..1192fde 100644 --- a/config/care_specialty_clinical.php +++ b/config/care_specialty_clinical.php @@ -34,11 +34,17 @@ return [ ], 'physiotherapy' => [ 'assessment' => 'pt_assessment', + 'plan' => 'pt_plan', 'session' => 'pt_session', + 'exercises' => 'pt_home_program', ], 'maternity' => [ - 'antenatal' => 'antenatal', - 'clinical_notes' => 'clinical_note', + 'history' => 'anc_history', + 'exam' => 'obstetric_exam', + 'fetal' => 'fetal_notes', + 'investigations' => 'mat_investigation', + 'plan' => 'mat_plan', + 'postnatal' => 'postnatal_note', ], 'radiology' => [ 'request' => 'imaging_request', @@ -249,36 +255,105 @@ return [ ['name' => 'chief_complaint', 'label' => 'Presenting complaint', 'type' => 'text', 'required' => true], ['name' => 'onset', 'label' => 'Onset / mechanism', 'type' => 'text'], ['name' => 'pain_score', 'label' => 'Pain score (0–10)', 'type' => 'number'], + ['name' => 'body_region', 'label' => 'Body region', 'type' => 'select', 'options' => ['Neck', 'Shoulder', 'Back / lumbar', 'Hip', 'Knee', 'Ankle / foot', 'Upper limb', 'Lower limb', 'Other']], ['name' => 'rom', 'label' => 'Range of motion', 'type' => 'textarea', 'rows' => 2], ['name' => 'strength', 'label' => 'Strength / function', 'type' => 'textarea', 'rows' => 2], + ['name' => 'special_tests', 'label' => 'Special tests', 'type' => 'textarea', 'rows' => 2], + ['name' => 'red_flags', 'label' => 'Red flags', 'type' => 'textarea', 'rows' => 2], ['name' => 'goals', 'label' => 'Treatment goals', 'type' => 'textarea', 'required' => true, 'rows' => 2], ], + 'pt_plan' => [ + ['name' => 'diagnosis', 'label' => 'Physio diagnosis / problem list', 'type' => 'text', 'required' => true], + ['name' => 'frequency', 'label' => 'Session frequency', 'type' => 'text'], + ['name' => 'duration_weeks', 'label' => 'Expected duration (weeks)', 'type' => 'number'], + ['name' => 'interventions', 'label' => 'Planned interventions', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'precautions', 'label' => 'Precautions / contraindications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'goals', 'label' => 'Short-term goals', 'type' => 'textarea', 'rows' => 2], + ['name' => 'outcome_measures', 'label' => 'Outcome measures', 'type' => 'text'], + ], 'pt_session' => [ + ['name' => 'session_number', 'label' => 'Session number', 'type' => 'number'], ['name' => 'modalities', 'label' => 'Modalities used', 'type' => 'textarea', 'required' => true, 'rows' => 2], - ['name' => 'exercises', 'label' => 'Exercises prescribed', 'type' => 'textarea', 'rows' => 2], + ['name' => 'exercises', 'label' => 'Exercises performed', 'type' => 'textarea', 'rows' => 2], + ['name' => 'pain_before', 'label' => 'Pain before (0–10)', 'type' => 'number'], + ['name' => 'pain_after', 'label' => 'Pain after (0–10)', 'type' => 'number'], ['name' => 'response', 'label' => 'Patient response', 'type' => 'textarea', 'rows' => 2], - ['name' => 'home_program', 'label' => 'Home program', 'type' => 'textarea', 'rows' => 2], + ['name' => 'outcome', 'label' => 'Session outcome', 'type' => 'select', 'required' => true, 'options' => ['In progress', 'Completed — continue plan', 'Completed — progress review due', 'Completed — discharge ready', 'Deferred']], + ['name' => 'next_review', 'label' => 'Next review', 'type' => 'text'], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], + ], + 'pt_home_program' => [ + ['name' => 'exercises', 'label' => 'Home exercises', 'type' => 'textarea', 'required' => true, 'rows' => 4], + ['name' => 'frequency', 'label' => 'Frequency / sets', 'type' => 'text'], + ['name' => 'precautions', 'label' => 'Precautions', 'type' => 'textarea', 'rows' => 2], + ['name' => 'progress_notes', 'label' => 'Progress notes', 'type' => 'textarea', 'rows' => 3], + ['name' => 'adherence', 'label' => 'Adherence', 'type' => 'select', 'options' => ['Good', 'Fair', 'Poor', 'Unknown']], + ['name' => 'outcome', 'label' => 'Progress outcome', 'type' => 'select', 'options' => ['Improving', 'Stable', 'Worsening', 'Goals met — discharge', 'Refer on']], ['name' => 'next_review', 'label' => 'Next review', 'type' => 'text'], ], ], 'maternity' => [ - 'antenatal' => [ + 'anc_history' => [ ['name' => 'gravida', 'label' => 'Gravida', 'type' => 'number', 'required' => true], ['name' => 'para', 'label' => 'Para', 'type' => 'number'], + ['name' => 'lmp', 'label' => 'LMP', 'type' => 'text'], + ['name' => 'edd', 'label' => 'EDD', 'type' => 'text'], ['name' => 'gestational_age_weeks', 'label' => 'Gestational age (weeks)', 'type' => 'number', 'required' => true], - ['name' => 'bp', 'label' => 'Blood pressure', 'type' => 'text'], + ['name' => 'anc_visit_number', 'label' => 'ANC visit number', 'type' => 'number'], + ['name' => 'obstetric_history', 'label' => 'Obstetric history', 'type' => 'textarea', 'rows' => 3], + ['name' => 'medical_history', 'label' => 'Medical / surgical history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'], + ['name' => 'medications', 'label' => 'Current medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'social_history', 'label' => 'Social history', 'type' => 'textarea', 'rows' => 2], + ], + 'obstetric_exam' => [ + ['name' => 'bp', 'label' => 'Blood pressure', 'type' => 'text', 'required' => true], + ['name' => 'pulse', 'label' => 'Pulse', 'type' => 'number'], + ['name' => 'weight_kg', 'label' => 'Weight (kg)', 'type' => 'number'], ['name' => 'fundal_height', 'label' => 'Fundal height (cm)', 'type' => 'number'], - ['name' => 'fetal_heart_rate', 'label' => 'Fetal heart rate', 'type' => 'number'], ['name' => 'presentation', 'label' => 'Presentation', 'type' => 'select', 'options' => ['Cephalic', 'Breech', 'Transverse', 'Unknown']], + ['name' => 'lie', 'label' => 'Lie', 'type' => 'select', 'options' => ['Longitudinal', 'Oblique', 'Transverse', 'Unknown']], + ['name' => 'engagement', 'label' => 'Engagement', 'type' => 'text'], + ['name' => 'oedema', 'label' => 'Oedema', 'type' => 'select', 'options' => ['None', 'Mild', 'Moderate', 'Severe']], ['name' => 'urine', 'label' => 'Urine dipstick', 'type' => 'text'], ['name' => 'danger_signs', 'label' => 'Danger signs', 'type' => 'textarea', 'rows' => 2], - ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'required' => true, 'rows' => 2], + ['name' => 'findings', 'label' => 'Exam findings', '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], + 'fetal_notes' => [ + ['name' => 'fetal_heart_rate', 'label' => 'Fetal heart rate (bpm)', 'type' => 'number', 'required' => true], + ['name' => 'fetal_movements', 'label' => 'Fetal movements', 'type' => 'select', 'options' => ['Normal', 'Reduced', 'Absent', 'Not assessed']], + ['name' => 'presentation', 'label' => 'Presentation', 'type' => 'select', 'options' => ['Cephalic', 'Breech', 'Transverse', 'Unknown']], + ['name' => 'liquor', 'label' => 'Liquor / AFI notes', 'type' => 'text'], + ['name' => 'ctg_summary', 'label' => 'CTG / monitoring summary', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Fetal notes', 'type' => 'textarea', 'rows' => 3], + ], + 'mat_investigation' => [ + ['name' => 'modality', 'label' => 'Investigation', 'type' => 'select', 'required' => true, 'options' => ['Obstetric ultrasound', 'Bloods (FBC / group)', 'Urine / MSU', 'Glucose / GTT', 'HIV / syphilis / hepatitis', 'Other']], + ['name' => 'indication', 'label' => 'Indication', 'type' => 'textarea', 'rows' => 2], + ['name' => 'findings', 'label' => 'Findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'impression', 'label' => 'Impression', 'type' => 'textarea', 'rows' => 2], + ['name' => 'recommendations', 'label' => 'Recommendations', 'type' => 'textarea', 'rows' => 2], + ], + 'mat_plan' => [ + ['name' => 'risk_category', 'label' => 'Risk category', 'type' => 'select', 'options' => ['Low risk', 'Moderate risk', 'High risk']], + ['name' => 'plan', 'label' => 'Care plan', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'medications', 'label' => 'Medications / supplements', 'type' => 'textarea', 'rows' => 2], + ['name' => 'education', 'label' => 'Patient education', 'type' => 'textarea', 'rows' => 2], + ['name' => 'postnatal_needed', 'label' => 'Delivery / postnatal follow-up needed', 'type' => 'boolean'], + ['name' => 'follow_up', 'label' => 'Next ANC / follow-up', 'type' => 'text'], + ['name' => 'referral', 'label' => 'Referral', 'type' => 'text'], + ], + 'postnatal_note' => [ + ['name' => 'episode_type', 'label' => 'Episode type', 'type' => 'select', 'required' => true, 'options' => ['ANC only', 'Delivery note', 'Immediate postnatal', 'Postnatal review']], + ['name' => 'delivery_mode', 'label' => 'Delivery mode', 'type' => 'select', 'options' => ['N/A', 'SVD', 'Assisted vaginal', 'CS elective', 'CS emergency']], + ['name' => 'delivery_date', 'label' => 'Delivery date / time', 'type' => 'text'], + ['name' => 'baby_condition', 'label' => 'Baby condition', 'type' => 'textarea', 'rows' => 2], + ['name' => 'maternal_condition', 'label' => 'Maternal condition', 'type' => 'textarea', 'rows' => 2], + ['name' => 'breastfeeding', 'label' => 'Breastfeeding / feeding', 'type' => 'text'], + ['name' => 'complications', 'label' => 'Complications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['In progress', 'Completed — continue ANC', 'Completed — discharged', 'Referred', 'Admitted']], + ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], ], ], 'radiology' => [ diff --git a/config/care_specialty_modules.php b/config/care_specialty_modules.php index da5f2dd..2168b14 100644 --- a/config/care_specialty_modules.php +++ b/config/care_specialty_modules.php @@ -88,8 +88,9 @@ return [ 'nav_label' => 'Physiotherapy', 'icon' => 'arrow-path', 'queue_keywords' => ['physio', 'rehab', 'therapy'], - 'access' => 'general', + 'access' => 'limited', 'roles' => ['doctor', 'nurse', 'receptionist'], + 'specialist_keywords' => ['physio', 'rehab', 'therapy'], ], 'maternity' => [ 'label' => 'Maternity / Antenatal', @@ -99,10 +100,11 @@ return [ 'queue_name' => 'Maternity', 'queue_prefix' => 'MAT', 'nav_label' => 'Maternity', - 'icon' => 'home', + 'icon' => 'heart', 'queue_keywords' => ['matern', 'antenatal', 'obstetric'], - 'access' => 'general', + 'access' => 'limited', 'roles' => ['doctor', 'nurse', 'pharmacist', 'receptionist'], + 'specialist_keywords' => ['matern', 'antenatal', 'obstetric', 'midwif'], ], 'radiology' => [ 'label' => 'Radiology', diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index 621b24e..dcdc3b0 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -215,42 +215,80 @@ return [ ], 'physiotherapy' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], ['code' => 'assessment', 'label' => 'Assessment', 'queue_point' => 'chair'], - ['code' => 'treatment', 'label' => 'Treatment session', 'queue_point' => 'chair'], + ['code' => 'treatment_plan', 'label' => 'Treatment plan', 'queue_point' => 'chair'], + ['code' => 'session', 'label' => 'Therapy session', 'queue_point' => 'chair'], + ['code' => 'progress', 'label' => 'Progress review', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ ['code' => 'pt.assessment', 'label' => 'Physio assessment', 'amount_minor' => 5000, 'type' => 'consultation'], + ['code' => 'pt.plan', 'label' => 'Treatment plan review', 'amount_minor' => 4000, 'type' => 'consultation'], ['code' => 'pt.session', 'label' => 'Treatment session', 'amount_minor' => 7000, 'type' => 'procedure'], + ['code' => 'pt.manual', 'label' => 'Manual therapy', 'amount_minor' => 8000, 'type' => 'procedure'], + ['code' => 'pt.electro', 'label' => 'Electrotherapy / modalities', 'amount_minor' => 6000, 'type' => 'procedure'], + ['code' => 'pt.exercise', 'label' => 'Therapeutic exercise session', 'amount_minor' => 6500, 'type' => 'procedure'], + ['code' => 'pt.progress', 'label' => 'Progress / outcome review', 'amount_minor' => 4500, 'type' => 'consultation'], ], 'workspace_tabs' => [ 'overview' => 'Overview', 'assessment' => 'Assessment', - 'session' => 'Session notes', + 'plan' => 'Treatment plan', + 'session' => 'Sessions / progress', + 'exercises' => 'Exercises / home program', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'assessment' => 'assessment', + 'treatment_plan' => 'plan', + 'session' => 'session', + 'progress' => 'exercises', + 'completed' => 'overview', + ], ], 'maternity' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], - ['code' => 'antenatal', 'label' => 'Antenatal review', 'queue_point' => 'chair'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'history', 'label' => 'ANC history', 'queue_point' => 'waiting'], + ['code' => 'exam', 'label' => 'Exam / vitals', 'queue_point' => 'chair'], + ['code' => 'investigation', 'label' => 'Investigations', 'queue_point' => 'chair'], + ['code' => 'plan', 'label' => 'Care plan', 'queue_point' => 'chair'], + ['code' => 'postnatal', 'label' => 'Delivery / postnatal', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ ['code' => 'mat.anc', 'label' => 'Antenatal visit', 'amount_minor' => 5000, 'type' => 'consultation'], + ['code' => 'mat.history', 'label' => 'Obstetric history intake', 'amount_minor' => 3500, 'type' => 'consultation'], + ['code' => 'mat.exam', 'label' => 'Obstetric examination', 'amount_minor' => 4500, 'type' => 'procedure'], ['code' => 'mat.ultrasound', 'label' => 'Obstetric ultrasound', 'amount_minor' => 12000, 'type' => 'imaging'], + ['code' => 'mat.labs', 'label' => 'ANC investigations panel', 'amount_minor' => 8000, 'type' => 'procedure'], + ['code' => 'mat.postnatal', 'label' => 'Postnatal review', 'amount_minor' => 5000, 'type' => 'consultation'], ], 'workspace_tabs' => [ 'overview' => 'Overview', - 'antenatal' => 'Antenatal', - 'clinical_notes' => 'Clinical notes', + 'history' => 'ANC history', + 'exam' => 'Obstetric exam', + 'fetal' => 'Fetal notes', + 'investigations' => 'Investigations', + 'plan' => 'Care plan', + 'postnatal' => 'Delivery / postnatal', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'history' => 'history', + 'exam' => 'exam', + 'investigation' => 'investigations', + 'plan' => 'plan', + 'postnatal' => 'postnatal', + 'completed' => 'overview', + ], ], 'radiology' => [ 'stages' => [ diff --git a/resources/views/care/specialty/maternity/print.blade.php b/resources/views/care/specialty/maternity/print.blade.php new file mode 100644 index 0000000..4570858 --- /dev/null +++ b/resources/views/care/specialty/maternity/print.blade.php @@ -0,0 +1,106 @@ + + + + + Maternity 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') ?? '—' }} +

+ +

ANC history

+ @if ($history) +
+
G / P
{{ $history->payload['gravida'] ?? '—' }} / {{ $history->payload['para'] ?? '—' }}
+
GA / EDD
{{ $history->payload['gestational_age_weeks'] ?? '—' }} weeks · {{ $history->payload['edd'] ?? '—' }}
+
LMP
{{ $history->payload['lmp'] ?? '—' }}
+
Obstetric history
{{ $history->payload['obstetric_history'] ?? '—' }}
+
Medications
{{ $history->payload['medications'] ?? '—' }}
+
+ @else +

No ANC history recorded.

+ @endif + +

Obstetric exam

+ @if ($exam) +
+
BP / pulse
{{ $exam->payload['bp'] ?? '—' }} · {{ $exam->payload['pulse'] ?? '—' }}
+
Fundal height
{{ $exam->payload['fundal_height'] ?? '—' }} cm
+
Presentation
{{ $exam->payload['presentation'] ?? '—' }}
+
Urine
{{ $exam->payload['urine'] ?? '—' }}
+
Danger signs
{{ $exam->payload['danger_signs'] ?? '—' }}
+
Findings
{{ $exam->payload['findings'] ?? '—' }}
+
+ @else +

No obstetric exam recorded.

+ @endif + +

Fetal notes

+ @if ($fetal) +
+
FHR
{{ $fetal->payload['fetal_heart_rate'] ?? '—' }} bpm
+
Movements
{{ $fetal->payload['fetal_movements'] ?? '—' }}
+
Presentation
{{ $fetal->payload['presentation'] ?? '—' }}
+
Notes
{{ $fetal->payload['notes'] ?? '—' }}
+
+ @else +

No fetal notes recorded.

+ @endif + +

Investigations

+ @if ($investigation) +
+
Investigation
{{ $investigation->payload['modality'] ?? '—' }}
+
Findings
{{ $investigation->payload['findings'] ?? '—' }}
+
Impression
{{ $investigation->payload['impression'] ?? '—' }}
+
+ @else +

No investigation recorded.

+ @endif + +

Care plan

+ @if ($plan) +
+
Risk
{{ $plan->payload['risk_category'] ?? '—' }}
+
Plan
{{ $plan->payload['plan'] ?? '—' }}
+
Medications
{{ $plan->payload['medications'] ?? '—' }}
+
Follow-up
{{ $plan->payload['follow_up'] ?? '—' }}
+
+ @else +

No care plan recorded.

+ @endif + +

Delivery / postnatal

+ @if ($postnatal) +
+
Episode
{{ $postnatal->payload['episode_type'] ?? '—' }}
+
Delivery mode
{{ $postnatal->payload['delivery_mode'] ?? '—' }}
+
Outcome
{{ $postnatal->payload['outcome'] ?? '—' }}
+
Maternal
{{ $postnatal->payload['maternal_condition'] ?? '—' }}
+
Baby
{{ $postnatal->payload['baby_condition'] ?? '—' }}
+
Plan
{{ $postnatal->payload['plan'] ?? '—' }}
+
+ @else +

No postnatal note recorded.

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

Maternity reports

+

Arrivals, high-risk ANC, risk categories, postnatal outcomes, length of stay, and maternity service revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

High risk open

+

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

+
+
+

Avg LOS (range)

+

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

+
+
+ +
+
+

Risk categories

+
    + @forelse ($report['risk_breakdown'] as $row) +
  • + {{ $row['risk'] }} + {{ $row['total'] }} +
  • + @empty +
  • No care plans in range.
  • + @endforelse +
+
+ +
+

Postnatal outcomes

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

Maternity service revenue

+
    + @forelse ($report['revenue_by_service'] as $row) +
  • + {{ $row->description }} ×{{ $row->total }} + {{ $currency }} {{ number_format($row->amount_minor / 100, 2) }} +
  • + @empty +
  • No billed maternity services in range.
  • + @endforelse +
+
+
+
diff --git a/resources/views/care/specialty/maternity/workspace-overview.blade.php b/resources/views/care/specialty/maternity/workspace-overview.blade.php new file mode 100644 index 0000000..ad79c08 --- /dev/null +++ b/resources/views/care/specialty/maternity/workspace-overview.blade.php @@ -0,0 +1,79 @@ +@php + $historyPayload = $maternityHistory?->payload ?? []; + $examPayload = $maternityExam?->payload ?? []; + $fetalPayload = $maternityFetal?->payload ?? []; + $planPayload = $maternityPlan?->payload ?? []; +@endphp + +
+
+
+

Maternity overview

+

ANC history, exam, fetal notes, and care plan for this episode.

+
+ +
+ +
+
+

Pregnancy

+

+ G{{ $historyPayload['gravida'] ?? '—' }} P{{ $historyPayload['para'] ?? '—' }} +

+

+ {{ $historyPayload['gestational_age_weeks'] ?? '—' }} weeks + @if (! empty($historyPayload['edd'])) · EDD {{ $historyPayload['edd'] }} @endif +

+
+
+

Exam / FHR

+

+ BP {{ $examPayload['bp'] ?? '—' }} · FHR {{ $fetalPayload['fetal_heart_rate'] ?? '—' }} +

+

FH {{ $examPayload['fundal_height'] ?? '—' }} cm · {{ $examPayload['presentation'] ?? ($fetalPayload['presentation'] ?? '—') }}

+
+
+

Risk / plan

+

{{ $planPayload['risk_category'] ?? 'Not set' }}

+

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

+
+
+ +
+
+
Exam findings
+
{{ $examPayload['findings'] ?? '—' }}
+
+
+
Care plan
+
{{ $planPayload['plan'] ?? '—' }}
+
+
+
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/maternity/workspace-postnatal.blade.php b/resources/views/care/specialty/maternity/workspace-postnatal.blade.php new file mode 100644 index 0000000..ed67e25 --- /dev/null +++ b/resources/views/care/specialty/maternity/workspace-postnatal.blade.php @@ -0,0 +1,70 @@ +@php + $record = $maternityPostnatal; + $payload = $record?->payload ?? []; + $canManage = $canManageClinical ?? $canConsult ?? false; +@endphp + +
+
+
+

Delivery / postnatal

+

Clinic delivery notes and postnatal review. Discharge outcomes can close the 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 delivery / postnatal note recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/partials/actions-menu.blade.php b/resources/views/care/specialty/partials/actions-menu.blade.php index 85a17e0..a3b6c7c 100644 --- a/resources/views/care/specialty/partials/actions-menu.blade.php +++ b/resources/views/care/specialty/partials/actions-menu.blade.php @@ -43,6 +43,8 @@ 'blood_bank' => 'request', 'dentistry' => 'chair', 'ophthalmology' => 'check_in', + 'physiotherapy' => 'check_in', + 'maternity' => 'check_in', default => collect($stages ?? [])->pluck('code')->first(), }; $defaultStartLabel = match ($moduleKey) { @@ -50,6 +52,8 @@ 'blood_bank' => 'Start request', 'dentistry' => 'Seat at chair', 'ophthalmology' => 'Start check-in', + 'physiotherapy' => 'Start check-in', + 'maternity' => 'Start check-in', default => 'Start', }; $currentStage = $workspaceVisit?->specialty_stage; diff --git a/resources/views/care/specialty/physiotherapy/print.blade.php b/resources/views/care/specialty/physiotherapy/print.blade.php new file mode 100644 index 0000000..fc018a7 --- /dev/null +++ b/resources/views/care/specialty/physiotherapy/print.blade.php @@ -0,0 +1,81 @@ + + + + + Physiotherapy 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') ?? '—' }} +

+ +

Assessment

+ @if ($assessment) +
+
Complaint
{{ $assessment->payload['chief_complaint'] ?? '—' }}
+
Onset
{{ $assessment->payload['onset'] ?? '—' }}
+
Pain / region
{{ $assessment->payload['pain_score'] ?? '—' }} · {{ $assessment->payload['body_region'] ?? '—' }}
+
ROM
{{ $assessment->payload['rom'] ?? '—' }}
+
Strength
{{ $assessment->payload['strength'] ?? '—' }}
+
Goals
{{ $assessment->payload['goals'] ?? '—' }}
+
+ @else +

No assessment recorded.

+ @endif + +

Treatment plan

+ @if ($plan) +
+
Diagnosis
{{ $plan->payload['diagnosis'] ?? '—' }}
+
Frequency
{{ $plan->payload['frequency'] ?? '—' }}
+
Interventions
{{ $plan->payload['interventions'] ?? '—' }}
+
Precautions
{{ $plan->payload['precautions'] ?? '—' }}
+
+ @else +

No treatment plan recorded.

+ @endif + +

Therapy session

+ @if ($session) +
+
Modalities
{{ $session->payload['modalities'] ?? '—' }}
+
Exercises
{{ $session->payload['exercises'] ?? '—' }}
+
Pain before / after
{{ $session->payload['pain_before'] ?? '—' }} / {{ $session->payload['pain_after'] ?? '—' }}
+
Outcome
{{ $session->payload['outcome'] ?? '—' }}
+
Next review
{{ $session->payload['next_review'] ?? '—' }}
+
+ @else +

No session recorded.

+ @endif + +

Home program

+ @if ($homeProgram) +
+
Exercises
{{ $homeProgram->payload['exercises'] ?? '—' }}
+
Frequency
{{ $homeProgram->payload['frequency'] ?? '—' }}
+
Progress
{{ $homeProgram->payload['progress_notes'] ?? '—' }}
+
Outcome
{{ $homeProgram->payload['outcome'] ?? '—' }}
+
+ @else +

No home program recorded.

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

Physiotherapy reports

+

Arrivals, high pain, diagnoses, session outcomes, length of stay, and physio service revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

High pain open

+

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

+
+
+

Avg LOS (range)

+

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

+
+
+ +
+
+

Diagnoses

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

Session outcomes

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

Physiotherapy service revenue

+
    + @forelse ($report['revenue_by_service'] as $row) +
  • + {{ $row->description }} ×{{ $row->total }} + {{ $currency }} {{ number_format($row->amount_minor / 100, 2) }} +
  • + @empty +
  • No billed physiotherapy services in range.
  • + @endforelse +
+
+
+
diff --git a/resources/views/care/specialty/physiotherapy/workspace-overview.blade.php b/resources/views/care/specialty/physiotherapy/workspace-overview.blade.php new file mode 100644 index 0000000..3950701 --- /dev/null +++ b/resources/views/care/specialty/physiotherapy/workspace-overview.blade.php @@ -0,0 +1,71 @@ +@php + $assessmentPayload = $physiotherapyAssessment?->payload ?? []; + $planPayload = $physiotherapyPlan?->payload ?? []; + $sessionPayload = $physiotherapySession?->payload ?? []; +@endphp + +
+
+
+

Physiotherapy overview

+

Assessment, plan, and session summary for this episode.

+
+ +
+ +
+
+

Complaint

+

{{ $assessmentPayload['chief_complaint'] ?? 'Not set' }}

+

Pain {{ $assessmentPayload['pain_score'] ?? '—' }} / 10 · {{ $assessmentPayload['body_region'] ?? '—' }}

+
+
+

Diagnosis

+

{{ $planPayload['diagnosis'] ?? 'Not set' }}

+

{{ $planPayload['frequency'] ?? '—' }}

+
+
+

Latest session

+

{{ $sessionPayload['outcome'] ?? 'Not recorded' }}

+

{{ $sessionPayload['next_review'] ?? ($sessionPayload['modalities'] ?? '—') }}

+
+
+ +
+
+
Goals
+
{{ $assessmentPayload['goals'] ?? ($planPayload['goals'] ?? '—') }}
+
+
+
Interventions
+
{{ $planPayload['interventions'] ?? '—' }}
+
+
+
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/physiotherapy/workspace-session.blade.php b/resources/views/care/specialty/physiotherapy/workspace-session.blade.php new file mode 100644 index 0000000..e59c91a --- /dev/null +++ b/resources/views/care/specialty/physiotherapy/workspace-session.blade.php @@ -0,0 +1,70 @@ +@php + $record = $physiotherapySession; + $payload = $record?->payload ?? []; + $canManage = $canManageClinical ?? $canConsult ?? false; +@endphp + +
+
+
+

Therapy session / progress

+

Record modalities and response. Discharge-ready outcomes can close the 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 therapy session recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index 8b8985e..aeed64d 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -63,6 +63,10 @@ @include('care.specialty.blood-bank.workspace-'.$activeTab) @elseif ($moduleKey === 'ophthalmology' && in_array($activeTab, ['overview', 'treat'], true)) @include('care.specialty.ophthalmology.workspace-'.$activeTab) + @elseif ($moduleKey === 'physiotherapy' && in_array($activeTab, ['overview', 'session'], true)) + @include('care.specialty.physiotherapy.workspace-'.$activeTab) + @elseif ($moduleKey === 'maternity' && in_array($activeTab, ['overview', 'postnatal'], true)) + @include('care.specialty.maternity.workspace-'.$activeTab) @elseif ($activeTab === 'billing')

diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index bc74b89..6397f4f 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -37,6 +37,12 @@ @if ($moduleKey === 'ophthalmology') Reports @endif + @if ($moduleKey === 'physiotherapy') + Reports + @endif + @if ($moduleKey === 'maternity') + Reports + @endif History @if ($canManageClinical ?? $canManageSpecialty ?? true) Billing diff --git a/routes/web.php b/routes/web.php index 5b23df7..5366523 100644 --- a/routes/web.php +++ b/routes/web.php @@ -42,6 +42,8 @@ use App\Http\Controllers\Care\BloodBankWorkspaceController; use App\Http\Controllers\Care\LabAdminController; use App\Http\Controllers\Care\EmergencyWorkspaceController; use App\Http\Controllers\Care\OphthalmologyWorkspaceController; +use App\Http\Controllers\Care\PhysiotherapyWorkspaceController; +use App\Http\Controllers\Care\MaternityWorkspaceController; use App\Http\Controllers\Care\SpecialtyModuleController; use App\Http\Controllers\Care\VisitWorkflowController; use App\Http\Controllers\NotificationController; @@ -125,6 +127,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/specialty/emergency/reports', [EmergencyWorkspaceController::class, 'reports'])->name('care.specialty.emergency.reports'); Route::get('/specialty/blood-bank/reports', [BloodBankWorkspaceController::class, 'reports'])->name('care.specialty.blood-bank.reports'); Route::get('/specialty/ophthalmology/reports', [OphthalmologyWorkspaceController::class, 'reports'])->name('care.specialty.ophthalmology.reports'); + Route::get('/specialty/physiotherapy/reports', [PhysiotherapyWorkspaceController::class, 'reports'])->name('care.specialty.physiotherapy.reports'); + Route::get('/specialty/maternity/reports', [MaternityWorkspaceController::class, 'reports'])->name('care.specialty.maternity.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'); @@ -161,6 +165,12 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/specialty/ophthalmology/workspace/{visit}/stage', [OphthalmologyWorkspaceController::class, 'setStage'])->name('care.specialty.ophthalmology.stage'); Route::post('/specialty/ophthalmology/workspace/{visit}/procedure', [OphthalmologyWorkspaceController::class, 'saveProcedure'])->name('care.specialty.ophthalmology.procedure'); Route::get('/specialty/ophthalmology/workspace/{visit}/print', [OphthalmologyWorkspaceController::class, 'printSummary'])->name('care.specialty.ophthalmology.print'); + Route::post('/specialty/physiotherapy/workspace/{visit}/stage', [PhysiotherapyWorkspaceController::class, 'setStage'])->name('care.specialty.physiotherapy.stage'); + Route::post('/specialty/physiotherapy/workspace/{visit}/session', [PhysiotherapyWorkspaceController::class, 'saveSession'])->name('care.specialty.physiotherapy.session'); + Route::get('/specialty/physiotherapy/workspace/{visit}/print', [PhysiotherapyWorkspaceController::class, 'printSummary'])->name('care.specialty.physiotherapy.print'); + Route::post('/specialty/maternity/workspace/{visit}/stage', [MaternityWorkspaceController::class, 'setStage'])->name('care.specialty.maternity.stage'); + Route::post('/specialty/maternity/workspace/{visit}/postnatal', [MaternityWorkspaceController::class, 'savePostnatal'])->name('care.specialty.maternity.postnatal'); + Route::get('/specialty/maternity/workspace/{visit}/print', [MaternityWorkspaceController::class, 'printSummary'])->name('care.specialty.maternity.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/CareMaternitySuiteTest.php b/tests/Feature/CareMaternitySuiteTest.php new file mode 100644 index 0000000..1bb8223 --- /dev/null +++ b/tests/Feature/CareMaternitySuiteTest.php @@ -0,0 +1,240 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'mat-owner', + 'name' => 'Owner', + 'email' => 'mat-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Maternity Clinic', + 'slug' => 'maternity-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, + 'maternity', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'maternity') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-MAT-SUITE', + 'first_name' => 'Ama', + 'last_name' => 'Boateng', + 'gender' => 'female', + 'date_of_birth' => '1994-08-11', + ]); + + $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_history_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'maternity', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Maternity overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Check-in') + ->assertSee('ANC history') + ->assertSee('Start ANC history') + ->assertSee('Maternity reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'maternity', + 'visit' => $this->visit, + 'tab' => 'history', + ])) + ->assertOk() + ->assertSee('Gravida') + ->assertSee('Gestational age'); + } + + public function test_exam_sets_stage_and_danger_alerts(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'maternity', + 'visit' => $this->visit, + ]), [ + 'tab' => 'exam', + 'payload' => [ + 'bp' => '158/102', + 'fundal_height' => 32, + 'presentation' => 'Cephalic', + 'danger_signs' => 'Headache and visual spots', + 'findings' => 'Hypertension with oedema', + ], + ]) + ->assertRedirect(); + + $this->assertSame('exam', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'obstetric_exam') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('mat.danger_signs', $codes); + $this->assertContains('mat.hypertension', $codes); + } + + public function test_stage_advance_and_postnatal_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.maternity.stage', $this->visit), [ + 'stage' => 'postnatal', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'maternity', + 'visit' => $this->visit, + 'tab' => 'postnatal', + ])); + + $this->assertSame('postnatal', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.maternity.postnatal', $this->visit), [ + 'tab' => 'postnatal', + 'payload' => [ + 'episode_type' => 'Postnatal review', + 'delivery_mode' => 'SVD', + 'maternal_condition' => 'Stable', + 'baby_condition' => 'Well', + 'outcome' => 'Completed — discharged', + 'plan' => 'Routine postnatal advice', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'maternity', + 'visit' => $this->visit, + 'tab' => 'postnatal', + ])); + + $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' => 'postnatal_note', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.maternity.reports')) + ->assertOk() + ->assertSee('Maternity reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.maternity.print', $this->visit)) + ->assertOk() + ->assertSee('Maternity 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', 'maternity')) + ->assertOk() + ->assertDontSee('Maternity overview'); + } +} diff --git a/tests/Feature/CarePhysiotherapySuiteTest.php b/tests/Feature/CarePhysiotherapySuiteTest.php new file mode 100644 index 0000000..0794b11 --- /dev/null +++ b/tests/Feature/CarePhysiotherapySuiteTest.php @@ -0,0 +1,240 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'pt-owner', + 'name' => 'Owner', + 'email' => 'pt-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Physio Clinic', + 'slug' => 'physio-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, + 'physiotherapy', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'physiotherapy') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-PT-SUITE', + 'first_name' => 'Kwame', + 'last_name' => 'Asante', + 'gender' => 'male', + 'date_of_birth' => '1988-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_assessment_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'physiotherapy', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Physiotherapy overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Check-in') + ->assertSee('Treatment plan') + ->assertSee('Start assessment') + ->assertSee('Physio reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'physiotherapy', + 'visit' => $this->visit, + 'tab' => 'assessment', + ])) + ->assertOk() + ->assertSee('Presenting complaint') + ->assertSee('Pain score'); + } + + public function test_assessment_sets_stage_and_pain_alerts(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'physiotherapy', + 'visit' => $this->visit, + ]), [ + 'tab' => 'assessment', + 'payload' => [ + 'chief_complaint' => 'Low back pain', + 'pain_score' => 9, + 'body_region' => 'Back / lumbar', + 'goals' => 'Walk without pain', + 'red_flags' => 'Night pain', + ], + ]) + ->assertRedirect(); + + $this->assertSame('assessment', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'pt_assessment') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('pt.severe_pain', $codes); + $this->assertContains('pt.red_flags', $codes); + } + + public function test_stage_advance_and_session_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.physiotherapy.stage', $this->visit), [ + 'stage' => 'session', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'physiotherapy', + 'visit' => $this->visit, + 'tab' => 'session', + ])); + + $this->assertSame('session', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.physiotherapy.session', $this->visit), [ + 'tab' => 'session', + 'payload' => [ + 'modalities' => 'Manual therapy + exercise', + 'exercises' => 'Core activation', + 'pain_before' => 8, + 'pain_after' => 3, + 'outcome' => 'Completed — discharge ready', + 'next_review' => 'PRN', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'physiotherapy', + 'visit' => $this->visit, + 'tab' => 'session', + ])); + + $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' => 'pt_session', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.physiotherapy.reports')) + ->assertOk() + ->assertSee('Physiotherapy reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.physiotherapy.print', $this->visit)) + ->assertOk() + ->assertSee('Physiotherapy 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', 'physiotherapy')) + ->assertOk() + ->assertDontSee('Physiotherapy overview'); + } +} diff --git a/tests/Feature/CareSpecialtyShellTest.php b/tests/Feature/CareSpecialtyShellTest.php index 5b2bece..3bb5aea 100644 --- a/tests/Feature/CareSpecialtyShellTest.php +++ b/tests/Feature/CareSpecialtyShellTest.php @@ -88,6 +88,11 @@ class CareSpecialtyShellTest extends TestCase $this->assertSame('exam', $shell->workspaceTabForStage('ophthalmology', 'exam')); $this->assertSame('investigations', $shell->workspaceTabForStage('ophthalmology', 'investigation')); $this->assertSame('treat', $shell->workspaceTabForStage('ophthalmology', 'treatment')); + $this->assertSame('assessment', $shell->workspaceTabForStage('physiotherapy', 'assessment')); + $this->assertSame('session', $shell->workspaceTabForStage('physiotherapy', 'session')); + $this->assertSame('exercises', $shell->workspaceTabForStage('physiotherapy', 'progress')); + $this->assertSame('history', $shell->workspaceTabForStage('maternity', 'history')); + $this->assertSame('postnatal', $shell->workspaceTabForStage('maternity', 'postnatal')); $this->assertSame('odontogram', $shell->workspaceTabForStage('dentistry', 'chair')); $this->assertSame('issue', $shell->workspaceTabForStage('blood_bank', 'issue')); }