From 7b722c71d04cd0f832b7ddab060e243a9236dbb2 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sun, 19 Jul 2026 21:39:15 +0000 Subject: [PATCH] Add full Vaccination, Pathology, and Infusion specialty clinical suites. Mirror Oncology-depth stage flows, workspace controllers, clinical forms, reports/print, demo seed, and suite tests. Pathology stays clinic specialty complementary to the Lab app. Co-authored-by: Cursor --- .../Care/InfusionWorkspaceController.php | 214 ++++++++++++++++ .../Care/PathologyWorkspaceController.php | 215 ++++++++++++++++ .../Care/SpecialtyModuleController.php | 170 +++++++++++++ .../Care/VaccinationWorkspaceController.php | 214 ++++++++++++++++ app/Services/Care/DemoTenantSeeder.php | 224 +++++++++++++++++ .../Infusion/InfusionAnalyticsService.php | 163 ++++++++++++ .../Care/Infusion/InfusionWorkflowService.php | 103 ++++++++ .../Pathology/PathologyAnalyticsService.php | 163 ++++++++++++ .../Pathology/PathologyWorkflowService.php | 118 +++++++++ .../Care/SpecialtyClinicalRecordService.php | 71 ++++++ app/Services/Care/SpecialtyShellService.php | 14 +- .../VaccinationAnalyticsService.php | 163 ++++++++++++ .../VaccinationWorkflowService.php | 104 ++++++++ config/care_specialty_clinical.php | 90 ++++++- config/care_specialty_modules.php | 4 +- config/care_specialty_shell.php | 69 +++++- .../care/specialty/infusion/print.blade.php | 31 +++ .../care/specialty/infusion/reports.blade.php | 87 +++++++ .../infusion/workspace-monitoring.blade.php | 70 ++++++ .../infusion/workspace-overview.blade.php | 48 ++++ .../care/specialty/pathology/print.blade.php | 33 +++ .../specialty/pathology/reports.blade.php | 87 +++++++ .../pathology/workspace-overview.blade.php | 47 ++++ .../workspace-verification.blade.php | 70 ++++++ .../specialty/sections/workspace.blade.php | 6 + .../views/care/specialty/shell.blade.php | 9 + .../specialty/vaccination/print.blade.php | 31 +++ .../specialty/vaccination/reports.blade.php | 87 +++++++ .../workspace-observation.blade.php | 70 ++++++ .../vaccination/workspace-overview.blade.php | 47 ++++ routes/web.php | 15 ++ tests/Feature/CareInfusionSuiteTest.php | 230 +++++++++++++++++ tests/Feature/CarePathologySuiteTest.php | 232 +++++++++++++++++ tests/Feature/CareVaccinationSuiteTest.php | 233 ++++++++++++++++++ 34 files changed, 3508 insertions(+), 24 deletions(-) create mode 100644 app/Http/Controllers/Care/InfusionWorkspaceController.php create mode 100644 app/Http/Controllers/Care/PathologyWorkspaceController.php create mode 100644 app/Http/Controllers/Care/VaccinationWorkspaceController.php create mode 100644 app/Services/Care/Infusion/InfusionAnalyticsService.php create mode 100644 app/Services/Care/Infusion/InfusionWorkflowService.php create mode 100644 app/Services/Care/Pathology/PathologyAnalyticsService.php create mode 100644 app/Services/Care/Pathology/PathologyWorkflowService.php create mode 100644 app/Services/Care/Vaccination/VaccinationAnalyticsService.php create mode 100644 app/Services/Care/Vaccination/VaccinationWorkflowService.php create mode 100644 resources/views/care/specialty/infusion/print.blade.php create mode 100644 resources/views/care/specialty/infusion/reports.blade.php create mode 100644 resources/views/care/specialty/infusion/workspace-monitoring.blade.php create mode 100644 resources/views/care/specialty/infusion/workspace-overview.blade.php create mode 100644 resources/views/care/specialty/pathology/print.blade.php create mode 100644 resources/views/care/specialty/pathology/reports.blade.php create mode 100644 resources/views/care/specialty/pathology/workspace-overview.blade.php create mode 100644 resources/views/care/specialty/pathology/workspace-verification.blade.php create mode 100644 resources/views/care/specialty/vaccination/print.blade.php create mode 100644 resources/views/care/specialty/vaccination/reports.blade.php create mode 100644 resources/views/care/specialty/vaccination/workspace-observation.blade.php create mode 100644 resources/views/care/specialty/vaccination/workspace-overview.blade.php create mode 100644 tests/Feature/CareInfusionSuiteTest.php create mode 100644 tests/Feature/CarePathologySuiteTest.php create mode 100644 tests/Feature/CareVaccinationSuiteTest.php diff --git a/app/Http/Controllers/Care/InfusionWorkspaceController.php b/app/Http/Controllers/Care/InfusionWorkspaceController.php new file mode 100644 index 0000000..be3afd0 --- /dev/null +++ b/app/Http/Controllers/Care/InfusionWorkspaceController.php @@ -0,0 +1,214 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'infusion'), 403); + } + + protected function assertInfusionManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'infusion'), 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->assertInfusionManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'infusion', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'infusion', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('infusion', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveMonitoring( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + InfusionWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertInfusionManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('infusion', 'infusion_monitoring') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'monitoring']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('infusion', 'infusion_monitoring'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'infusion', + 'infusion_monitoring', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + InfusionWorkflowService::STAGE_MONITORING, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'infusion', + InfusionWorkflowService::STAGE_MONITORING, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'infusion', + InfusionWorkflowService::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' => 'infusion', 'visit' => $visit, 'tab' => 'monitoring']) + ->with('success', 'Infusion monitoring saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + InfusionAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertInfusionAccess($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.infusion.reports', [ + 'moduleKey' => 'infusion', + 'definition' => $modules->definition('infusion'), + 'shellNav' => $shell->navItems('infusion'), + '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->assertInfusionAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.infusion.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'assessment' => $clinical->findForVisit($visit, 'infusion', 'infusion_assessment'), + 'protocol' => $clinical->findForVisit($visit, 'infusion', 'infusion_protocol'), + 'session' => $clinical->findForVisit($visit, 'infusion', 'infusion_session'), + 'monitoring' => $clinical->findForVisit($visit, 'infusion', 'infusion_monitoring'), + ]); + } +} diff --git a/app/Http/Controllers/Care/PathologyWorkspaceController.php b/app/Http/Controllers/Care/PathologyWorkspaceController.php new file mode 100644 index 0000000..e26cc76 --- /dev/null +++ b/app/Http/Controllers/Care/PathologyWorkspaceController.php @@ -0,0 +1,215 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'pathology'), 403); + } + + protected function assertPathologyManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'pathology'), 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->assertPathologyManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'pathology', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'pathology', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('pathology', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveVerification( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + PathologyWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertPathologyManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('pathology', 'path_verification') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'verification']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('pathology', 'path_verification'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'pathology', + 'path_verification', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + PathologyWorkflowService::STAGE_VERIFIED, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'pathology', + PathologyWorkflowService::STAGE_VERIFIED, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'pathology', + PathologyWorkflowService::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' => 'pathology', 'visit' => $visit, 'tab' => 'verification']) + ->with('success', 'Pathology verification saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + PathologyAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertPathologyAccess($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.pathology.reports', [ + 'moduleKey' => 'pathology', + 'definition' => $modules->definition('pathology'), + 'shellNav' => $shell->navItems('pathology'), + '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->assertPathologyAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.pathology.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'requestRecord' => $clinical->findForVisit($visit, 'pathology', 'path_request'), + 'specimen' => $clinical->findForVisit($visit, 'pathology', 'path_specimen'), + 'processing' => $clinical->findForVisit($visit, 'pathology', 'path_processing'), + 'report' => $clinical->findForVisit($visit, 'pathology', 'path_report'), + 'verification' => $clinical->findForVisit($visit, 'pathology', 'path_verification'), + ]); + } +} diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index b4b5fb7..c8fffc2 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -165,6 +165,9 @@ class SpecialtyModuleController extends Controller 'oncology' => 'history', 'renal' => 'history', 'surgery' => 'history', + 'vaccination' => 'eligibility', + 'pathology' => 'request', + 'infusion' => 'assessment', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -248,6 +251,9 @@ class SpecialtyModuleController extends Controller 'oncology' => 'history', 'renal' => 'history', 'surgery' => 'history', + 'vaccination' => 'eligibility', + 'pathology' => 'request', + 'infusion' => 'assessment', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -915,6 +921,104 @@ class SpecialtyModuleController extends Controller } } + if ($module === 'vaccination' && in_array($recordType, ['vax_eligibility', 'vax_consent', 'vax_admin'], true)) { + $workflow = app(\App\Services\Care\Vaccination\VaccinationWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'vax_eligibility' => $workflow->stageFromEligibility($record->payload ?? []), + 'vax_consent' => $workflow->stageFromConsent($record->payload ?? []), + 'vax_admin' => $workflow->stageFromAdmin($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'eligibility' => 1, + 'consent' => 2, + 'administer' => 3, + 'observation' => 4, + 'completed' => 5, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage($organization, $visit, 'vaccination', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage($organization, $visit, 'vaccination', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } + } + + if ($module === 'pathology' && in_array($recordType, ['path_request', 'path_specimen', 'path_processing', 'path_report'], true)) { + $workflow = app(\App\Services\Care\Pathology\PathologyWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'path_request' => $workflow->stageFromRequest($record->payload ?? []), + 'path_specimen' => $workflow->stageFromSpecimen($record->payload ?? []), + 'path_processing' => $workflow->stageFromProcessing($record->payload ?? []), + 'path_report' => $workflow->stageFromReport($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'request' => 1, + 'specimen' => 2, + 'processing' => 3, + 'report' => 4, + 'verified' => 5, + 'completed' => 6, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage($organization, $visit, 'pathology', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage($organization, $visit, 'pathology', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } + } + + if ($module === 'infusion' && in_array($recordType, ['infusion_assessment', 'infusion_protocol', 'infusion_session'], true)) { + $workflow = app(\App\Services\Care\Infusion\InfusionWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'infusion_assessment' => $workflow->stageFromAssessment($record->payload ?? []), + 'infusion_protocol' => $workflow->stageFromProtocol($record->payload ?? []), + 'infusion_session' => $workflow->stageFromSession($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'assessment' => 1, + 'protocol' => 2, + 'session' => 3, + 'monitoring' => 4, + 'completed' => 5, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage($organization, $visit, 'infusion', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage($organization, $visit, 'infusion', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } + } + return redirect() ->route('care.specialty.workspace', [ 'module' => $module, @@ -1392,6 +1496,25 @@ class SpecialtyModuleController extends Controller $surgeryPostop = null; $surgeryStageCodes = []; $surgeryStageFlow = []; + $vaccinationEligibility = null; + $vaccinationConsent = null; + $vaccinationAdmin = null; + $vaccinationObservation = null; + $vaccinationStageCodes = []; + $vaccinationStageFlow = []; + $pathologyRequest = null; + $pathologySpecimen = null; + $pathologyProcessing = null; + $pathologyReport = null; + $pathologyVerification = null; + $pathologyStageCodes = []; + $pathologyStageFlow = []; + $infusionAssessment = null; + $infusionProtocol = null; + $infusionSession = null; + $infusionMonitoring = null; + $infusionStageCodes = []; + $infusionStageFlow = []; $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); $clinical = app(SpecialtyClinicalRecordService::class); @@ -1616,6 +1739,34 @@ class SpecialtyModuleController extends Controller $surgeryStageFlow = app(\App\Services\Care\Surgery\SurgeryWorkflowService::class)->stageFlow(); } + if ($module === 'vaccination') { + $vaccinationEligibility = $clinical->findForVisit($workspaceVisit, 'vaccination', 'vax_eligibility'); + $vaccinationConsent = $clinical->findForVisit($workspaceVisit, 'vaccination', 'vax_consent'); + $vaccinationAdmin = $clinical->findForVisit($workspaceVisit, 'vaccination', 'vax_admin'); + $vaccinationObservation = $clinical->findForVisit($workspaceVisit, 'vaccination', 'vax_observation'); + $vaccinationStageCodes = collect($shell->stages('vaccination'))->pluck('code')->all(); + $vaccinationStageFlow = app(\App\Services\Care\Vaccination\VaccinationWorkflowService::class)->stageFlow(); + } + + if ($module === 'pathology') { + $pathologyRequest = $clinical->findForVisit($workspaceVisit, 'pathology', 'path_request'); + $pathologySpecimen = $clinical->findForVisit($workspaceVisit, 'pathology', 'path_specimen'); + $pathologyProcessing = $clinical->findForVisit($workspaceVisit, 'pathology', 'path_processing'); + $pathologyReport = $clinical->findForVisit($workspaceVisit, 'pathology', 'path_report'); + $pathologyVerification = $clinical->findForVisit($workspaceVisit, 'pathology', 'path_verification'); + $pathologyStageCodes = collect($shell->stages('pathology'))->pluck('code')->all(); + $pathologyStageFlow = app(\App\Services\Care\Pathology\PathologyWorkflowService::class)->stageFlow(); + } + + if ($module === 'infusion') { + $infusionAssessment = $clinical->findForVisit($workspaceVisit, 'infusion', 'infusion_assessment'); + $infusionProtocol = $clinical->findForVisit($workspaceVisit, 'infusion', 'infusion_protocol'); + $infusionSession = $clinical->findForVisit($workspaceVisit, 'infusion', 'infusion_session'); + $infusionMonitoring = $clinical->findForVisit($workspaceVisit, 'infusion', 'infusion_monitoring'); + $infusionStageCodes = collect($shell->stages('infusion'))->pluck('code')->all(); + $infusionStageFlow = app(\App\Services\Care\Infusion\InfusionWorkflowService::class)->stageFlow(); + } + if ($patientHeader !== null) { $patientHeader['clinical_alerts'] = $clinicalAlerts; } @@ -1785,6 +1936,25 @@ class SpecialtyModuleController extends Controller 'surgeryPostop' => $surgeryPostop, 'surgeryStageCodes' => $surgeryStageCodes, 'surgeryStageFlow' => $surgeryStageFlow, + 'vaccinationEligibility' => $vaccinationEligibility, + 'vaccinationConsent' => $vaccinationConsent, + 'vaccinationAdmin' => $vaccinationAdmin, + 'vaccinationObservation' => $vaccinationObservation, + 'vaccinationStageCodes' => $vaccinationStageCodes, + 'vaccinationStageFlow' => $vaccinationStageFlow, + 'pathologyRequest' => $pathologyRequest, + 'pathologySpecimen' => $pathologySpecimen, + 'pathologyProcessing' => $pathologyProcessing, + 'pathologyReport' => $pathologyReport, + 'pathologyVerification' => $pathologyVerification, + 'pathologyStageCodes' => $pathologyStageCodes, + 'pathologyStageFlow' => $pathologyStageFlow, + 'infusionAssessment' => $infusionAssessment, + 'infusionProtocol' => $infusionProtocol, + 'infusionSession' => $infusionSession, + 'infusionMonitoring' => $infusionMonitoring, + 'infusionStageCodes' => $infusionStageCodes, + 'infusionStageFlow' => $infusionStageFlow, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'branchLabel' => $branchLabel, diff --git a/app/Http/Controllers/Care/VaccinationWorkspaceController.php b/app/Http/Controllers/Care/VaccinationWorkspaceController.php new file mode 100644 index 0000000..02c28e6 --- /dev/null +++ b/app/Http/Controllers/Care/VaccinationWorkspaceController.php @@ -0,0 +1,214 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'vaccination'), 403); + } + + protected function assertVaccinationManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'vaccination'), 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->assertVaccinationManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'vaccination', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'vaccination', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('vaccination', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveObservation( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + VaccinationWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertVaccinationManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('vaccination', 'vax_observation') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'observation']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('vaccination', 'vax_observation'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'vaccination', + 'vax_observation', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + VaccinationWorkflowService::STAGE_OBSERVATION, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'vaccination', + VaccinationWorkflowService::STAGE_OBSERVATION, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'vaccination', + VaccinationWorkflowService::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' => 'vaccination', 'visit' => $visit, 'tab' => 'observation']) + ->with('success', 'Vaccination observation saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + VaccinationAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertVaccinationAccess($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.vaccination.reports', [ + 'moduleKey' => 'vaccination', + 'definition' => $modules->definition('vaccination'), + 'shellNav' => $shell->navItems('vaccination'), + '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->assertVaccinationAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.vaccination.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'eligibility' => $clinical->findForVisit($visit, 'vaccination', 'vax_eligibility'), + 'consent' => $clinical->findForVisit($visit, 'vaccination', 'vax_consent'), + 'admin' => $clinical->findForVisit($visit, 'vaccination', 'vax_admin'), + 'observation' => $clinical->findForVisit($visit, 'vaccination', 'vax_observation'), + ]); + } +} diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 970087f..4227d23 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -1026,6 +1026,9 @@ class DemoTenantSeeder $this->seedOncologyClinicalDemo($organization, $ownerRef); $this->seedRenalClinicalDemo($organization, $ownerRef); $this->seedSurgeryClinicalDemo($organization, $ownerRef); + $this->seedVaccinationClinicalDemo($organization, $ownerRef); + $this->seedPathologyClinicalDemo($organization, $ownerRef); + $this->seedInfusionClinicalDemo($organization, $ownerRef); return $count; } @@ -2445,6 +2448,227 @@ class DemoTenantSeeder } } + private function seedVaccinationClinicalDemo(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', 'vaccination') + ->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; + } + $alertCase = $index === 0; + $clinical->upsert($organization, $visit, 'vaccination', 'vax_eligibility', [ + 'vaccine' => $alertCase ? 'Yellow fever' : 'Tetanus toxoid', + 'schedule' => $alertCase ? 'Travel' : 'Routine EPI', + 'prior_doses' => $alertCase ? 'None documented' : 'Primary series complete', + 'indications' => $alertCase ? 'Travel certificate required' : 'Antenatal TT booster', + 'contraindications' => true, + 'allergy_history' => 'NKDA', + 'fever_today' => $alertCase, + 'pregnancy' => ! $alertCase, + 'cleared' => ! $alertCase, + ], $ownerRef, $ownerRef, 'eligibility'); + $clinical->upsert($organization, $visit, 'vaccination', 'vax_consent', [ + 'information_given' => true, + 'consent_obtained' => ! $alertCase, + 'consent_type' => 'Verbal', + 'consented_by' => 'Patient', + 'risks_discussed' => true, + 'notes' => $alertCase ? 'Defer pending fever resolution' : 'Consent for TT booster', + ], $ownerRef, $ownerRef, $alertCase ? 'eligibility' : 'consent'); + if (! $alertCase) { + $clinical->upsert($organization, $visit, 'vaccination', 'vax_admin', [ + 'vaccine' => 'Tetanus toxoid', + 'dose' => '0.5 mL', + 'site' => 'Left arm', + 'batch' => 'TT-DEMO-'.($index + 1), + 'expiry' => now()->addYear()->toDateString(), + 'route' => 'IM', + 'administered_by' => 'Demo nurse', + 'adverse_events' => 'None immediate', + 'next_due' => now()->addMonths(6)->toDateString(), + 'outcome' => 'Completed — observe', + ], $ownerRef, $ownerRef, 'administer'); + } + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $alertCase ? 'eligibility' : 'administer']); + } + $index++; + } + } + + private function seedPathologyClinicalDemo(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', 'pathology') + ->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; + } + $urgent = $index === 0; + $clinical->upsert($organization, $visit, 'pathology', 'path_request', [ + 'specimen_type' => $urgent ? 'Breast core biopsy' : 'Cervical cytology', + 'site' => $urgent ? 'Left breast' : 'Cervix', + 'clinical_history' => $urgent + ? 'Palpable mass; BIRADS 4; rule out malignancy' + : 'Routine Pap smear; previous NILM', + 'urgency' => $urgent ? 'Urgent' : 'Routine', + 'tests_requested' => $urgent ? 'Histo + IHC if indicated' : 'Pap smear', + 'requesting_clinician' => 'Demo clinician', + ], $ownerRef, $ownerRef, 'request'); + $clinical->upsert($organization, $visit, 'pathology', 'path_specimen', [ + 'received_at' => now()->subHours(2)->format('Y-m-d H:i'), + 'container_label' => 'PATH-DEMO-'.($index + 1), + 'adequacy' => $urgent ? 'Adequate' : 'Adequate', + 'fixative' => $urgent ? '10% formalin' : 'Liquid-based cytology', + 'notes' => 'Received sealed and labelled', + ], $ownerRef, $ownerRef, 'specimen'); + if ($urgent) { + $clinical->upsert($organization, $visit, 'pathology', 'path_processing', [ + 'gross' => 'Two cores, 1.2 cm aggregate', + 'blocks' => 'A1–A2', + 'stains' => 'H&E', + 'status' => 'Ready for reporting', + 'notes' => 'Priority processing', + ], $ownerRef, $ownerRef, 'processing'); + } + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $urgent ? 'processing' : 'specimen']); + } + $index++; + } + } + + private function seedInfusionClinicalDemo(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', 'infusion') + ->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; + } + $notFit = $index === 0; + $clinical->upsert($organization, $visit, 'infusion', 'infusion_assessment', [ + 'indication' => $notFit ? 'IV iron — symptomatic anaemia' : 'IV antibiotics — cellulitis', + 'history' => $notFit + ? 'Hb 7.8; prior infusion reaction reported' + : 'Day 3 of outpatient IV ceftriaxone', + 'allergies' => $notFit ? 'Possible iron sucrose reaction' : 'NKDA', + 'baseline_vitals' => $notFit ? 'BP 88/54; HR 112' : 'BP 118/74; HR 78', + 'access_plan' => 'Peripheral IV', + 'fit_for_infusion' => ! $notFit, + ], $ownerRef, $ownerRef, 'assessment'); + if (! $notFit) { + $clinical->upsert($organization, $visit, 'infusion', 'infusion_protocol', [ + 'medication' => 'Ceftriaxone', + 'dose' => '2 g', + 'diluent' => '100 mL NS', + 'rate' => '30 minutes', + 'duration' => '30 min', + 'premeds' => 'None', + 'ordered_by' => 'Demo clinician', + ], $ownerRef, $ownerRef, 'protocol'); + $clinical->upsert($organization, $visit, 'infusion', 'infusion_session', [ + 'medication' => 'Ceftriaxone', + 'dose' => '2 g', + 'rate' => '30 min', + 'access' => 'Left forearm PIV', + 'start_time' => now()->subMinutes(20)->format('H:i'), + 'end_time' => '', + 'vitals' => 'Stable mid-infusion', + 'reactions' => 'None', + 'notes' => 'Infusing without incident', + 'outcome' => 'In progress', + ], $ownerRef, $ownerRef, 'session'); + } + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $notFit ? 'assessment' : 'session']); + } + $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/Infusion/InfusionAnalyticsService.php b/app/Services/Care/Infusion/InfusionAnalyticsService.php new file mode 100644 index 0000000..aa9dc68 --- /dev/null +++ b/app/Services/Care/Infusion/InfusionAnalyticsService.php @@ -0,0 +1,163 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'infusion', $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'); + + $sessionsOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'infusion') + ->where('record_type', 'infusion_session') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->where('status', '!=', SpecialtyClinicalRecord::STATUS_COMPLETED) + ->count(); + + $sessionRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'infusion') + ->where('record_type', 'infusion_session') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $medicationBreakdown = $sessionRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['medication'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $medication) => [ + 'medication' => $medication, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $monitoringRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'infusion') + ->where('record_type', 'infusion_monitoring') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $reactionBreakdown = $monitoringRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['reaction'] ?? 'None')) + ->map(fn (Collection $rows, string $reaction) => [ + 'reaction' => $reaction, + '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, 'infusion')) + ->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, + 'sessions_open' => $sessionsOpen, + 'medication_breakdown' => $medicationBreakdown, + 'reaction_breakdown' => $reactionBreakdown, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/Infusion/InfusionWorkflowService.php b/app/Services/Care/Infusion/InfusionWorkflowService.php new file mode 100644 index 0000000..aac16af --- /dev/null +++ b/app/Services/Care/Infusion/InfusionWorkflowService.php @@ -0,0 +1,103 @@ + $payload + */ + public function stageFromAssessment(array $payload): string + { + if (! empty($payload['indication']) || ! empty($payload['fit_for_infusion'])) { + return self::STAGE_ASSESSMENT; + } + + return self::STAGE_CHECK_IN; + } + + /** + * @param array $payload + */ + public function stageFromProtocol(array $payload): string + { + if (! empty($payload['medication']) && ! empty($payload['dose'])) { + return self::STAGE_PROTOCOL; + } + + return self::STAGE_ASSESSMENT; + } + + /** + * @param array $payload + */ + public function stageFromSession(array $payload): string + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + if (str_contains($outcome, 'observe') || str_contains($outcome, 'completed') || ! empty($payload['end_time'])) { + return self::STAGE_MONITORING; + } + + if (! empty($payload['notes']) || ! empty($payload['start_time'])) { + return self::STAGE_SESSION; + } + + return self::STAGE_PROTOCOL; + } + + /** + * @param array $payload + */ + public function stageFromMonitoring(array $payload): string + { + if ($this->shouldCompleteVisit($payload)) { + return self::STAGE_COMPLETED; + } + + if (! empty($payload['outcome']) || ! empty($payload['post_vitals'])) { + return self::STAGE_MONITORING; + } + + return self::STAGE_SESSION; + } + + /** + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return str_contains($outcome, 'completed'); + } + + /** + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_CHECK_IN => ['next' => self::STAGE_ASSESSMENT, 'label' => 'Start assessment'], + self::STAGE_ASSESSMENT => ['next' => self::STAGE_PROTOCOL, 'label' => 'Move to protocol'], + self::STAGE_PROTOCOL => ['next' => self::STAGE_SESSION, 'label' => 'Move to infusion session'], + self::STAGE_SESSION => ['next' => self::STAGE_MONITORING, 'label' => 'Move to monitoring'], + self::STAGE_MONITORING => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'], + self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'], + ]; + } +} diff --git a/app/Services/Care/Pathology/PathologyAnalyticsService.php b/app/Services/Care/Pathology/PathologyAnalyticsService.php new file mode 100644 index 0000000..abbe958 --- /dev/null +++ b/app/Services/Care/Pathology/PathologyAnalyticsService.php @@ -0,0 +1,163 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'pathology', $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'); + + $reportsOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'pathology') + ->where('record_type', 'path_report') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->where('status', '!=', SpecialtyClinicalRecord::STATUS_COMPLETED) + ->count(); + + $reportRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'pathology') + ->where('record_type', 'path_report') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $diagnosisBreakdown = $reportRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => \Illuminate\Support\Str::limit((string) ($r->payload['diagnosis'] ?? 'Unknown'), 60)) + ->map(fn (Collection $rows, string $diagnosis) => [ + 'diagnosis' => $diagnosis, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $requestRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'pathology') + ->where('record_type', 'path_request') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $specimenBreakdown = $requestRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['specimen_type'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $specimen) => [ + 'specimen' => $specimen, + '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, 'pathology')) + ->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, + 'reports_open' => $reportsOpen, + 'diagnosis_breakdown' => $diagnosisBreakdown, + 'specimen_breakdown' => $specimenBreakdown, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/Pathology/PathologyWorkflowService.php b/app/Services/Care/Pathology/PathologyWorkflowService.php new file mode 100644 index 0000000..b31ad64 --- /dev/null +++ b/app/Services/Care/Pathology/PathologyWorkflowService.php @@ -0,0 +1,118 @@ + $payload + */ + public function stageFromRequest(array $payload): string + { + if (! empty($payload['specimen_type']) || ! empty($payload['clinical_history'])) { + return self::STAGE_REQUEST; + } + + return self::STAGE_CHECK_IN; + } + + /** + * @param array $payload + */ + public function stageFromSpecimen(array $payload): string + { + if (! empty($payload['container_label'])) { + return self::STAGE_SPECIMEN; + } + + return self::STAGE_REQUEST; + } + + /** + * @param array $payload + */ + public function stageFromProcessing(array $payload): string + { + $status = strtolower((string) ($payload['status'] ?? '')); + if (str_contains($status, 'ready')) { + return self::STAGE_REPORT; + } + + if (! empty($payload['status']) || ! empty($payload['gross'])) { + return self::STAGE_PROCESSING; + } + + return self::STAGE_SPECIMEN; + } + + /** + * @param array $payload + */ + public function stageFromReport(array $payload): string + { + if (! empty($payload['diagnosis']) && ! empty($payload['microscopy'])) { + return self::STAGE_REPORT; + } + + return self::STAGE_PROCESSING; + } + + /** + * @param array $payload + */ + public function stageFromVerification(array $payload): string + { + if ($this->shouldCompleteVisit($payload)) { + return self::STAGE_COMPLETED; + } + + if (! empty($payload['verified'])) { + return self::STAGE_VERIFIED; + } + + return self::STAGE_REPORT; + } + + /** + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return str_contains($outcome, 'completed'); + } + + /** + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_CHECK_IN => ['next' => self::STAGE_REQUEST, 'label' => 'Start request'], + self::STAGE_REQUEST => ['next' => self::STAGE_SPECIMEN, 'label' => 'Move to specimen'], + self::STAGE_SPECIMEN => ['next' => self::STAGE_PROCESSING, 'label' => 'Move to processing'], + self::STAGE_PROCESSING => ['next' => self::STAGE_REPORT, 'label' => 'Move to report'], + self::STAGE_REPORT => ['next' => self::STAGE_VERIFIED, 'label' => 'Move to verification'], + self::STAGE_VERIFIED => ['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 ead7880..fc00591 100644 --- a/app/Services/Care/SpecialtyClinicalRecordService.php +++ b/app/Services/Care/SpecialtyClinicalRecordService.php @@ -483,6 +483,77 @@ class SpecialtyClinicalRecordService } } + if ($moduleKey === 'vaccination' && $recordType === 'vax_eligibility') { + if (empty($payload['cleared']) && ! empty($payload['vaccine'])) { + $alerts[] = [ + 'code' => 'vax.not_cleared', + 'severity' => 'critical', + 'message' => 'Vaccine requested but patient is not cleared to vaccinate.', + ]; + } + if (! empty($payload['fever_today'])) { + $alerts[] = [ + 'code' => 'vax.fever', + 'severity' => 'warning', + 'message' => 'Fever recorded on vaccination eligibility screen.', + ]; + } + } + + if ($moduleKey === 'vaccination' && $recordType === 'vax_observation') { + $aefi = strtolower((string) ($payload['aefi'] ?? '')); + if (str_contains($aefi, 'severe')) { + $alerts[] = [ + 'code' => 'vax.aefi_severe', + 'severity' => 'critical', + 'message' => 'Severe AEFI recorded — escalate and document follow-up.', + ]; + } + } + + if ($moduleKey === 'pathology' && $recordType === 'path_request') { + $urgency = strtolower((string) ($payload['urgency'] ?? '')); + if (str_contains($urgency, 'urgent') || str_contains($urgency, 'intra')) { + $alerts[] = [ + 'code' => 'path.urgent', + 'severity' => 'warning', + 'message' => 'Urgent / intra-op pathology request — prioritise processing.', + ]; + } + } + + if ($moduleKey === 'pathology' && $recordType === 'path_specimen') { + $adequacy = strtolower((string) ($payload['adequacy'] ?? '')); + if (str_contains($adequacy, 'inadequate')) { + $alerts[] = [ + 'code' => 'path.inadequate', + 'severity' => 'critical', + 'message' => 'Specimen marked inadequate — notify requesting clinician.', + ]; + } + } + + if ($moduleKey === 'infusion' && $recordType === 'infusion_assessment') { + if (empty($payload['fit_for_infusion']) && ! empty($payload['indication'])) { + $alerts[] = [ + 'code' => 'inf.not_fit', + 'severity' => 'critical', + 'message' => 'Indication recorded but patient not marked fit for infusion.', + ]; + } + } + + if ($moduleKey === 'infusion' && in_array($recordType, ['infusion_session', 'infusion_monitoring'], true)) { + $reaction = strtolower((string) ($payload['reactions'] ?? $payload['reaction'] ?? '')); + if (str_contains($reaction, 'severe') || str_contains($reaction, 'stop')) { + $alerts[] = [ + 'code' => 'inf.reaction', + 'severity' => 'critical', + 'message' => 'Infusion reaction / stop flagged — review interventions.', + ]; + } + } + if ($moduleKey === 'renal' && $recordType === 'renal_exam') { $fluid = strtolower((string) ($payload['fluid_status'] ?? '')); if (str_contains($fluid, 'overload')) { diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php index a4620f0..4fc49ec 100644 --- a/app/Services/Care/SpecialtyShellService.php +++ b/app/Services/Care/SpecialtyShellService.php @@ -23,6 +23,9 @@ use App\Services\Care\Ent\EntWorkflowService; use App\Services\Care\Oncology\OncologyWorkflowService; use App\Services\Care\Renal\RenalWorkflowService; use App\Services\Care\Surgery\SurgeryWorkflowService; +use App\Services\Care\Vaccination\VaccinationWorkflowService; +use App\Services\Care\Pathology\PathologyWorkflowService; +use App\Services\Care\Infusion\InfusionWorkflowService; use Illuminate\Support\Collection; /** @@ -78,6 +81,9 @@ class SpecialtyShellService 'oncology' => 'care.specialty.oncology.stage', 'renal' => 'care.specialty.renal.stage', 'surgery' => 'care.specialty.surgery.stage', + 'vaccination' => 'care.specialty.vaccination.stage', + 'pathology' => 'care.specialty.pathology.stage', + 'infusion' => 'care.specialty.infusion.stage', default => null, }; } @@ -104,6 +110,9 @@ class SpecialtyShellService 'oncology' => app(OncologyWorkflowService::class)->stageFlow(), 'renal' => app(RenalWorkflowService::class)->stageFlow(), 'surgery' => app(SurgeryWorkflowService::class)->stageFlow(), + 'vaccination' => app(VaccinationWorkflowService::class)->stageFlow(), + 'pathology' => app(PathologyWorkflowService::class)->stageFlow(), + 'infusion' => app(InfusionWorkflowService::class)->stageFlow(), 'dentistry' => [ 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], @@ -403,7 +412,7 @@ class SpecialtyShellService ) { $code = (string) ($stage['code'] ?? ''); - if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery'], true) && $visitIds->isNotEmpty()) { + if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery', 'vaccination', 'pathology', 'infusion'], true) && $visitIds->isNotEmpty()) { $count = Visit::owned($ownerRef) ->where('organization_id', $organization->id) ->whereIn('id', $visitIds) @@ -445,6 +454,9 @@ class SpecialtyShellService 'oncology' => $clinical->countOpenByType($organization, 'oncology', 'onc_staging', $branchScope), 'renal' => $clinical->countOpenByType($organization, 'renal', 'renal_exam', $branchScope), 'surgery' => $clinical->countOpenByType($organization, 'surgery', 'surg_exam', $branchScope), + 'vaccination' => $clinical->countOpenByType($organization, 'vaccination', 'vax_eligibility', $branchScope), + 'pathology' => $clinical->countOpenByType($organization, 'pathology', 'path_request', $branchScope), + 'infusion' => $clinical->countOpenByType($organization, 'infusion', 'infusion_assessment', $branchScope), 'dentistry' => \App\Models\DentalPlanItem::query() ->whereHas('plan', function ($q) use ($organization, $ownerRef) { $q->owned($ownerRef) diff --git a/app/Services/Care/Vaccination/VaccinationAnalyticsService.php b/app/Services/Care/Vaccination/VaccinationAnalyticsService.php new file mode 100644 index 0000000..61c20a7 --- /dev/null +++ b/app/Services/Care/Vaccination/VaccinationAnalyticsService.php @@ -0,0 +1,163 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'vaccination', $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'); + + $observationOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'vaccination') + ->where('record_type', 'vax_observation') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->where('status', '!=', SpecialtyClinicalRecord::STATUS_COMPLETED) + ->count(); + + $adminRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'vaccination') + ->where('record_type', 'vax_admin') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $vaccineBreakdown = $adminRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['vaccine'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $vaccine) => [ + 'vaccine' => $vaccine, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $observationRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'vaccination') + ->where('record_type', 'vax_observation') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $aefiBreakdown = $observationRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['aefi'] ?? 'None')) + ->map(fn (Collection $rows, string $aefi) => [ + 'aefi' => $aefi, + '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, 'vaccination')) + ->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, + 'observation_open' => $observationOpen, + 'vaccine_breakdown' => $vaccineBreakdown, + 'aefi_breakdown' => $aefiBreakdown, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/Vaccination/VaccinationWorkflowService.php b/app/Services/Care/Vaccination/VaccinationWorkflowService.php new file mode 100644 index 0000000..1da962a --- /dev/null +++ b/app/Services/Care/Vaccination/VaccinationWorkflowService.php @@ -0,0 +1,104 @@ + $payload + */ + public function stageFromEligibility(array $payload): string + { + if (! empty($payload['cleared']) || ! empty($payload['vaccine'])) { + return self::STAGE_ELIGIBILITY; + } + + return self::STAGE_CHECK_IN; + } + + /** + * @param array $payload + */ + public function stageFromConsent(array $payload): string + { + if (! empty($payload['consent_obtained'])) { + return self::STAGE_ADMINISTER; + } + + if (! empty($payload['information_given'])) { + return self::STAGE_CONSENT; + } + + return self::STAGE_ELIGIBILITY; + } + + /** + * @param array $payload + */ + public function stageFromAdmin(array $payload): string + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + if (str_contains($outcome, 'completed') || ! empty($payload['batch'])) { + return self::STAGE_OBSERVATION; + } + + return self::STAGE_ADMINISTER; + } + + /** + * @param array $payload + */ + public function stageFromObservation(array $payload): string + { + if ($this->shouldCompleteVisit($payload)) { + return self::STAGE_COMPLETED; + } + + $aefi = strtolower((string) ($payload['aefi'] ?? '')); + if ($aefi !== '' || ! empty($payload['outcome'])) { + return self::STAGE_OBSERVATION; + } + + return self::STAGE_ADMINISTER; + } + + /** + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return str_contains($outcome, 'completed'); + } + + /** + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_CHECK_IN => ['next' => self::STAGE_ELIGIBILITY, 'label' => 'Start eligibility'], + self::STAGE_ELIGIBILITY => ['next' => self::STAGE_CONSENT, 'label' => 'Move to consent'], + self::STAGE_CONSENT => ['next' => self::STAGE_ADMINISTER, 'label' => 'Move to administer'], + self::STAGE_ADMINISTER => ['next' => self::STAGE_OBSERVATION, 'label' => 'Move to observation'], + self::STAGE_OBSERVATION => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'], + self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'], + ]; + } +} diff --git a/config/care_specialty_clinical.php b/config/care_specialty_clinical.php index c744e3f..8cfbc10 100644 --- a/config/care_specialty_clinical.php +++ b/config/care_specialty_clinical.php @@ -108,16 +108,23 @@ return [ 'postop' => 'surg_postop', ], 'vaccination' => [ - 'screening' => 'vax_screening', - 'administration' => 'vax_admin', + 'eligibility' => 'vax_eligibility', + 'consent' => 'vax_consent', + 'administer' => 'vax_admin', + 'observation' => 'vax_observation', ], 'pathology' => [ 'request' => 'path_request', + 'specimen' => 'path_specimen', + 'processing' => 'path_processing', 'report' => 'path_report', + 'verification' => 'path_verification', ], 'infusion' => [ + 'assessment' => 'infusion_assessment', + 'protocol' => 'infusion_protocol', 'session' => 'infusion_session', - 'clinical_notes' => 'clinical_note', + 'monitoring' => 'infusion_monitoring', ], 'dermatology' => [ 'exam' => 'skin_exam', @@ -759,14 +766,25 @@ return [ ], ], 'vaccination' => [ - 'vax_screening' => [ - ['name' => 'vaccine', 'label' => 'Vaccine', 'type' => 'text', 'required' => true], + 'vax_eligibility' => [ + ['name' => 'vaccine', 'label' => 'Vaccine requested', 'type' => 'text', 'required' => true], + ['name' => 'schedule', 'label' => 'Schedule / indication', 'type' => 'select', 'options' => ['Routine EPI', 'Catch-up', 'Travel', 'Occupational', 'Campaign', 'Other']], + ['name' => 'prior_doses', 'label' => 'Prior doses / history', 'type' => 'textarea', 'rows' => 2], ['name' => 'indications', 'label' => 'Indications', 'type' => 'textarea', 'rows' => 2], ['name' => 'contraindications', 'label' => 'Contraindications checked', 'type' => 'boolean', 'required' => true], ['name' => 'allergy_history', 'label' => 'Allergy / prior reaction', 'type' => 'textarea', 'rows' => 2], ['name' => 'fever_today', 'label' => 'Fever today', 'type' => 'boolean'], + ['name' => 'pregnancy', 'label' => 'Pregnant / breastfeeding', 'type' => 'boolean'], ['name' => 'cleared', 'label' => 'Cleared to vaccinate', 'type' => 'boolean', 'required' => true], ], + 'vax_consent' => [ + ['name' => 'information_given', 'label' => 'Information given', 'type' => 'boolean', 'required' => true], + ['name' => 'consent_obtained', 'label' => 'Consent obtained', 'type' => 'boolean', 'required' => true], + ['name' => 'consent_type', 'label' => 'Consent type', 'type' => 'select', 'options' => ['Verbal', 'Written', 'Guardian', 'Implied']], + ['name' => 'consented_by', 'label' => 'Consented by', 'type' => 'text'], + ['name' => 'risks_discussed', 'label' => 'Risks / AEFI discussed', 'type' => 'boolean'], + ['name' => 'notes', 'label' => 'Consent notes', 'type' => 'textarea', 'rows' => 2], + ], 'vax_admin' => [ ['name' => 'vaccine', 'label' => 'Vaccine administered', 'type' => 'text', 'required' => true], ['name' => 'dose', 'label' => 'Dose / antigen', 'type' => 'text'], @@ -774,8 +792,18 @@ return [ ['name' => 'batch', 'label' => 'Batch / lot number', 'type' => 'text', 'required' => true], ['name' => 'expiry', 'label' => 'Expiry date', 'type' => 'text'], ['name' => 'route', 'label' => 'Route', 'type' => 'select', 'options' => ['IM', 'SC', 'ID', 'Oral', 'Nasal']], + ['name' => 'administered_by', 'label' => 'Administered by', 'type' => 'text'], ['name' => 'adverse_events', 'label' => 'Immediate adverse events', 'type' => 'textarea', 'rows' => 2], ['name' => 'next_due', 'label' => 'Next dose due', 'type' => 'text'], + ['name' => 'outcome', 'label' => 'Administration outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed — observe', 'Deferred', 'Refused', 'Incomplete']], + ], + 'vax_observation' => [ + ['name' => 'observation_minutes', 'label' => 'Observation minutes', 'type' => 'number'], + ['name' => 'vitals', 'label' => 'Vitals / condition', 'type' => 'textarea', 'rows' => 2], + ['name' => 'aefi', 'label' => 'AEFI', 'type' => 'select', 'options' => ['None', 'Mild', 'Moderate', 'Severe — escalate']], + ['name' => 'aefi_details', 'label' => 'AEFI details / actions', 'type' => 'textarea', 'rows' => 2], + ['name' => 'advice', 'label' => 'Home advice', 'type' => 'textarea', 'rows' => 2], + ['name' => 'outcome', 'label' => 'Disposition', 'type' => 'select', 'required' => true, 'options' => ['Completed — discharged', 'Completed — AEFI follow-up', 'Escalated', 'In progress']], ], ], 'pathology' => [ @@ -785,15 +813,55 @@ return [ ['name' => 'clinical_history', 'label' => 'Clinical history', 'type' => 'textarea', 'required' => true, 'rows' => 3], ['name' => 'urgency', 'label' => 'Urgency', 'type' => 'select', 'options' => ['Routine', 'Urgent', 'Intra-op']], ['name' => 'tests_requested', 'label' => 'Tests requested', 'type' => 'textarea', 'rows' => 2], + ['name' => 'requesting_clinician', 'label' => 'Requesting clinician', 'type' => 'text'], + ], + 'path_specimen' => [ + ['name' => 'received_at', 'label' => 'Received at', 'type' => 'text'], + ['name' => 'container_label', 'label' => 'Container / accession label', 'type' => 'text', 'required' => true], + ['name' => 'adequacy', 'label' => 'Specimen adequacy', 'type' => 'select', 'options' => ['Adequate', 'Suboptimal', 'Inadequate']], + ['name' => 'fixative', 'label' => 'Fixative / medium', 'type' => 'text'], + ['name' => 'notes', 'label' => 'Receipt notes', 'type' => 'textarea', 'rows' => 2], + ], + 'path_processing' => [ + ['name' => 'gross', 'label' => 'Gross description', 'type' => 'textarea', 'rows' => 3], + ['name' => 'blocks', 'label' => 'Blocks / slides', 'type' => 'text'], + ['name' => 'stains', 'label' => 'Stains / techniques', 'type' => 'textarea', 'rows' => 2], + ['name' => 'status', 'label' => 'Processing status', 'type' => 'select', 'required' => true, 'options' => ['In process', 'Ready for reporting', 'Hold — missing info']], + ['name' => 'notes', 'label' => 'Processing notes', 'type' => 'textarea', 'rows' => 2], ], 'path_report' => [ ['name' => 'gross', 'label' => 'Gross description', 'type' => 'textarea', 'rows' => 3], ['name' => 'microscopy', 'label' => 'Microscopy', 'type' => 'textarea', 'required' => true, 'rows' => 4], ['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'textarea', 'required' => true, 'rows' => 3], ['name' => 'comment', 'label' => 'Comment', 'type' => 'textarea', 'rows' => 2], + ['name' => 'reported_by', 'label' => 'Reported by', 'type' => 'text'], + ], + 'path_verification' => [ + ['name' => 'verified', 'label' => 'Report verified', 'type' => 'boolean', 'required' => true], + ['name' => 'verified_by', 'label' => 'Verified by', 'type' => 'text'], + ['name' => 'amendments', 'label' => 'Amendments', 'type' => 'textarea', 'rows' => 2], + ['name' => 'released', 'label' => 'Released to clinician', 'type' => 'boolean'], + ['name' => 'outcome', 'label' => 'Verification outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed — released', 'Completed — amended', 'Hold', 'In progress']], ], ], 'infusion' => [ + 'infusion_assessment' => [ + ['name' => 'indication', 'label' => 'Indication', 'type' => 'text', 'required' => true], + ['name' => 'history', 'label' => 'Relevant history', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'textarea', 'rows' => 2], + ['name' => 'baseline_vitals', 'label' => 'Baseline vitals', 'type' => 'textarea', 'rows' => 2], + ['name' => 'access_plan', 'label' => 'IV access plan', 'type' => 'text'], + ['name' => 'fit_for_infusion', 'label' => 'Fit for infusion', 'type' => 'boolean', 'required' => true], + ], + 'infusion_protocol' => [ + ['name' => 'medication', 'label' => 'Medication / regimen', 'type' => 'text', 'required' => true], + ['name' => 'dose', 'label' => 'Dose', 'type' => 'text', 'required' => true], + ['name' => 'diluent', 'label' => 'Diluent / volume', 'type' => 'text'], + ['name' => 'rate', 'label' => 'Ordered rate', 'type' => 'text'], + ['name' => 'duration', 'label' => 'Expected duration', 'type' => 'text'], + ['name' => 'premeds', 'label' => 'Premedications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'ordered_by', 'label' => 'Ordered by', 'type' => 'text'], + ], 'infusion_session' => [ ['name' => 'medication', 'label' => 'Medication / regimen', 'type' => 'text', 'required' => true], ['name' => 'dose', 'label' => 'Dose', 'type' => 'text'], @@ -804,12 +872,14 @@ return [ ['name' => 'vitals', 'label' => 'Vitals during infusion', 'type' => 'textarea', 'rows' => 2], ['name' => 'reactions', 'label' => 'Reactions / interventions', 'type' => 'textarea', 'rows' => 2], ['name' => 'notes', 'label' => 'Session notes', 'type' => 'textarea', 'required' => true, 'rows' => 2], + ['name' => 'outcome', 'label' => 'Session outcome', 'type' => 'select', 'options' => ['In progress', 'Completed — observe', 'Stopped — reaction', 'Deferred']], ], - '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], + 'infusion_monitoring' => [ + ['name' => 'post_vitals', 'label' => 'Post-infusion vitals', 'type' => 'textarea', 'rows' => 2], + ['name' => 'reaction', 'label' => 'Delayed reaction', 'type' => 'select', 'options' => ['None', 'Mild', 'Moderate', 'Severe']], + ['name' => 'interventions', 'label' => 'Interventions', 'type' => 'textarea', 'rows' => 2], + ['name' => 'advice', 'label' => 'Discharge advice', 'type' => 'textarea', 'rows' => 2], + ['name' => 'outcome', 'label' => 'Disposition', 'type' => 'select', 'required' => true, 'options' => ['Completed — discharged', 'Completed — admit / escalate', 'In progress']], ], ], 'dermatology' => [ diff --git a/config/care_specialty_modules.php b/config/care_specialty_modules.php index ecc446a..f04ccde 100644 --- a/config/care_specialty_modules.php +++ b/config/care_specialty_modules.php @@ -265,10 +265,11 @@ return [ 'queue_keywords' => ['vaccin', 'immun', 'epi', 'injection'], 'access' => 'general', 'roles' => ['doctor', 'nurse', 'receptionist'], + 'specialist_keywords' => ['vaccin', 'immun', 'epi'], ], 'pathology' => [ 'label' => 'Pathology', - 'description' => 'Histopathology, cytology, and pathology service counters.', + 'description' => 'Clinic histopathology and cytology specialty flow (complementary to the Lab app queue).', 'department_type' => 'pathology', 'department_name' => 'Pathology', 'queue_name' => 'Pathology', @@ -292,6 +293,7 @@ return [ 'queue_keywords' => ['infusion', 'iv', 'drip', 'day care'], 'access' => 'general', 'roles' => ['doctor', 'nurse', 'pharmacist', 'receptionist'], + 'specialist_keywords' => ['infusion', 'iv therapy', 'day care'], ], 'dermatology' => [ 'label' => 'Dermatology', diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index 43b4558..01f8d20 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -624,59 +624,104 @@ return [ ], 'vaccination' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], - ['code' => 'screening', 'label' => 'Screening', 'queue_point' => 'chair'], - ['code' => 'vaccinate', 'label' => 'Vaccination', 'queue_point' => 'chair'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'eligibility', 'label' => 'History / eligibility', 'queue_point' => 'chair'], + ['code' => 'consent', 'label' => 'Consent', 'queue_point' => 'chair'], + ['code' => 'administer', 'label' => 'Administer', 'queue_point' => 'chair'], + ['code' => 'observation', 'label' => 'Observation / AEFI', 'queue_point' => 'obs'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ + ['code' => 'vax.consult', 'label' => 'Immunization counselling', 'amount_minor' => 2000, 'type' => 'consultation'], ['code' => 'vax.admin', 'label' => 'Vaccine administration', 'amount_minor' => 3000, 'type' => 'procedure'], + ['code' => 'vax.travel', 'label' => 'Travel vaccine package', 'amount_minor' => 8000, 'type' => 'procedure'], ], 'workspace_tabs' => [ 'overview' => 'Overview', - 'screening' => 'Screening', - 'administration' => 'Administration', + 'eligibility' => 'History / eligibility', + 'consent' => 'Consent', + 'administer' => 'Administer', + 'observation' => 'Observation / AEFI', + 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'eligibility' => 'eligibility', + 'consent' => 'consent', + 'administer' => 'administer', + 'observation' => 'observation', + 'completed' => 'overview', + ], ], 'pathology' => [ 'stages' => [ - ['code' => 'request', 'label' => 'Specimen received', 'queue_point' => 'waiting'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'request', 'label' => 'Request', 'queue_point' => 'waiting'], + ['code' => 'specimen', 'label' => 'Specimen', 'queue_point' => 'lab'], ['code' => 'processing', 'label' => 'Processing', 'queue_point' => 'lab'], - ['code' => 'reporting', 'label' => 'Reporting', 'queue_point' => 'lab'], + ['code' => 'report', 'label' => 'Report', 'queue_point' => 'lab'], + ['code' => 'verified', 'label' => 'Verified', 'queue_point' => 'lab'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ ['code' => 'path.histo', 'label' => 'Histopathology', 'amount_minor' => 20000, 'type' => 'lab'], ['code' => 'path.cyto', 'label' => 'Cytology', 'amount_minor' => 15000, 'type' => 'lab'], + ['code' => 'path.special', 'label' => 'Special stains / IHC', 'amount_minor' => 25000, 'type' => 'lab'], ], 'workspace_tabs' => [ 'overview' => 'Overview', - 'request' => 'Specimen request', + 'request' => 'Request', + 'specimen' => 'Specimen', + 'processing' => 'Processing', 'report' => 'Report', + 'verification' => 'Verification', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'request' => 'request', + 'specimen' => 'specimen', + 'processing' => 'processing', + 'report' => 'report', + 'verified' => 'verification', + 'completed' => 'overview', + ], ], 'infusion' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], - ['code' => 'infusion', 'label' => 'Infusion', 'queue_point' => 'chair'], - ['code' => 'observation', 'label' => 'Observation', 'queue_point' => 'obs'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'assessment', 'label' => 'Assessment', 'queue_point' => 'chair'], + ['code' => 'protocol', 'label' => 'Order / protocol', 'queue_point' => 'chair'], + ['code' => 'session', 'label' => 'Infusion session', 'queue_point' => 'chair'], + ['code' => 'monitoring', 'label' => 'Monitoring', 'queue_point' => 'obs'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ + ['code' => 'inf.assess', 'label' => 'Infusion assessment', 'amount_minor' => 4000, 'type' => 'consultation'], ['code' => 'inf.session', 'label' => 'Infusion session', 'amount_minor' => 15000, 'type' => 'procedure'], + ['code' => 'inf.observe', 'label' => 'Post-infusion observation', 'amount_minor' => 3000, 'type' => 'misc'], ], 'workspace_tabs' => [ 'overview' => 'Overview', + 'assessment' => 'Assessment', + 'protocol' => 'Protocol / order', 'session' => 'Infusion session', - 'clinical_notes' => 'Clinical notes', + 'monitoring' => 'Monitoring', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'assessment' => 'assessment', + 'protocol' => 'protocol', + 'session' => 'session', + 'monitoring' => 'monitoring', + 'completed' => 'overview', + ], ], 'dermatology' => [ 'stages' => [ diff --git a/resources/views/care/specialty/infusion/print.blade.php b/resources/views/care/specialty/infusion/print.blade.php new file mode 100644 index 0000000..1359269 --- /dev/null +++ b/resources/views/care/specialty/infusion/print.blade.php @@ -0,0 +1,31 @@ + + + + + Infusion summary · {{ $patient->fullName() }} + + + +

Back

+

{{ $patient->fullName() }}

+

{{ $patient->patient_number }} · Visit #{{ $visit->id }} · Stage {{ $visit->specialty_stage ?? '—' }}

+

Assessment

+ @if ($assessment)
Indication
{{ $assessment->payload['indication'] ?? '—' }}
Fit
{{ ! empty($assessment->payload['fit_for_infusion']) ? 'Yes' : 'No' }}
@else

No assessment recorded.

@endif +

Protocol

+ @if ($protocol)
Medication
{{ $protocol->payload['medication'] ?? '—' }}
Dose
{{ $protocol->payload['dose'] ?? '—' }}
Rate
{{ $protocol->payload['rate'] ?? '—' }}
@else

No protocol recorded.

@endif +

Session

+ @if ($session)
Medication
{{ $session->payload['medication'] ?? '—' }}
Start
{{ $session->payload['start_time'] ?? '—' }}
End
{{ $session->payload['end_time'] ?? '—' }}
@else

No session recorded.

@endif +

Monitoring

+ @if ($monitoring)
Reaction
{{ $monitoring->payload['reaction'] ?? '—' }}
Outcome
{{ $monitoring->payload['outcome'] ?? '—' }}
@else

No monitoring recorded.

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

Infusion reports

+

Arrivals, open sessions, medications, reactions, length of stay, and infusion revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

Sessions open

+

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

+
+
+

Avg LOS (range)

+

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

+
+
+ +
+
+

Medications / regimens

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

Reactions

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

Infusion service revenue

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

Monitoring

+

Completed monitoring can close the infusion 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 monitoring recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/infusion/workspace-overview.blade.php b/resources/views/care/specialty/infusion/workspace-overview.blade.php new file mode 100644 index 0000000..2434cd4 --- /dev/null +++ b/resources/views/care/specialty/infusion/workspace-overview.blade.php @@ -0,0 +1,48 @@ +@php + $assess = $infusionAssessment?->payload ?? []; + $proto = $infusionProtocol?->payload ?? []; + $sess = $infusionSession?->payload ?? []; + $mon = $infusionMonitoring?->payload ?? []; +@endphp + +
+
+
+

Infusion overview

+

Assessment, protocol, session, and post-infusion monitoring for this episode.

+
+ +
+ +
+
+

Indication

+

{{ $assess['indication'] ?? '—' }}

+

{{ ! empty($assess['fit_for_infusion']) ? 'Fit for infusion' : '—' }}

+
+
+

Protocol

+

{{ $proto['medication'] ?? ($sess['medication'] ?? '—') }}

+

{{ $proto['dose'] ?? ($sess['dose'] ?? '—') }}

+
+
+

Monitoring

+

{{ $mon['reaction'] ?? '—' }}

+

{{ $mon['outcome'] ?? ($sess['outcome'] ?? '—') }}

+
+
+ + @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

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

+ @endforeach +
+ @endif +
diff --git a/resources/views/care/specialty/pathology/print.blade.php b/resources/views/care/specialty/pathology/print.blade.php new file mode 100644 index 0000000..6fe5759 --- /dev/null +++ b/resources/views/care/specialty/pathology/print.blade.php @@ -0,0 +1,33 @@ + + + + + Pathology summary · {{ $patient->fullName() }} + + + +

Back

+

{{ $patient->fullName() }}

+

{{ $patient->patient_number }} · Visit #{{ $visit->id }} · Stage {{ $visit->specialty_stage ?? '—' }}

+

Request

+ @if ($requestRecord)
Specimen
{{ $requestRecord->payload['specimen_type'] ?? '—' }}
Site
{{ $requestRecord->payload['site'] ?? '—' }}
History
{{ $requestRecord->payload['clinical_history'] ?? '—' }}
@else

No request recorded.

@endif +

Specimen

+ @if ($specimen)
Label
{{ $specimen->payload['container_label'] ?? '—' }}
Adequacy
{{ $specimen->payload['adequacy'] ?? '—' }}
@else

No specimen receipt recorded.

@endif +

Processing

+ @if ($processing)
Status
{{ $processing->payload['status'] ?? '—' }}
Gross
{{ $processing->payload['gross'] ?? '—' }}
@else

No processing recorded.

@endif +

Report

+ @if ($report)
Microscopy
{{ $report->payload['microscopy'] ?? '—' }}
Diagnosis
{{ $report->payload['diagnosis'] ?? '—' }}
@else

No report recorded.

@endif +

Verification

+ @if ($verification)
Verified
{{ ! empty($verification->payload['verified']) ? 'Yes' : 'No' }}
Outcome
{{ $verification->payload['outcome'] ?? '—' }}
@else

No verification recorded.

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

Pathology reports

+

Arrivals, open reports, diagnoses, specimens, length of stay, and pathology revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

Reports open

+

{{ number_format($report['reports_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 pathology reports in range.
  • + @endforelse +
+
+
+

Specimen types

+
    + @forelse ($report['specimen_breakdown'] as $row) +
  • + {{ $row['specimen'] }} + {{ $row['total'] }} +
  • + @empty +
  • No requests in range.
  • + @endforelse +
+
+
+ +
+

Pathology service revenue

+
    + @forelse ($report['revenue_by_service'] as $row) +
  • + {{ $row->description }} ×{{ $row->total }} + {{ $currency }} {{ number_format($row->amount_minor / 100, 2) }} +
  • + @empty +
  • No billed pathology services in range.
  • + @endforelse +
+
+
+
diff --git a/resources/views/care/specialty/pathology/workspace-overview.blade.php b/resources/views/care/specialty/pathology/workspace-overview.blade.php new file mode 100644 index 0000000..d2c6ca6 --- /dev/null +++ b/resources/views/care/specialty/pathology/workspace-overview.blade.php @@ -0,0 +1,47 @@ +@php + $req = $pathologyRequest?->payload ?? []; + $rep = $pathologyReport?->payload ?? []; + $ver = $pathologyVerification?->payload ?? []; +@endphp + +
+
+
+

Pathology overview

+

Clinic pathology request through verification — complementary to the Lab app queue.

+
+ +
+ +
+
+

Specimen

+

{{ $req['specimen_type'] ?? '—' }}

+

{{ $req['site'] ?? '—' }}

+
+
+

Urgency

+

{{ $req['urgency'] ?? '—' }}

+

{{ \Illuminate\Support\Str::limit($req['clinical_history'] ?? '—', 40) }}

+
+
+

Diagnosis

+

{{ \Illuminate\Support\Str::limit($rep['diagnosis'] ?? '—', 40) }}

+

{{ $ver['outcome'] ?? (! empty($ver['verified']) ? 'Verified' : '—') }}

+
+
+ + @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

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

+ @endforeach +
+ @endif +
diff --git a/resources/views/care/specialty/pathology/workspace-verification.blade.php b/resources/views/care/specialty/pathology/workspace-verification.blade.php new file mode 100644 index 0000000..ff5ec86 --- /dev/null +++ b/resources/views/care/specialty/pathology/workspace-verification.blade.php @@ -0,0 +1,70 @@ +@php + $record = $pathologyVerification; + $payload = $record?->payload ?? []; + $canManage = $canManageClinical ?? $canConsult ?? false; +@endphp + +
+
+
+

Verification

+

Verified / released reports can close the pathology 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'] }}
+
{{ is_bool($payload[$field['name']] ?? null) ? (! empty($payload[$field['name']]) ? 'Yes' : 'No') : ($payload[$field['name']] ?? '—') }}
+
+ @endforeach +
+ @else +

No verification recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index 0a3fca3..42a23fc 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -85,6 +85,12 @@ @include('care.specialty.renal.workspace-'.$activeTab) @elseif ($moduleKey === 'surgery' && in_array($activeTab, ['overview', 'postop'], true)) @include('care.specialty.surgery.workspace-'.$activeTab) + @elseif ($moduleKey === 'vaccination' && in_array($activeTab, ['overview', 'observation'], true)) + @include('care.specialty.vaccination.workspace-'.$activeTab) + @elseif ($moduleKey === 'pathology' && in_array($activeTab, ['overview', 'verification'], true)) + @include('care.specialty.pathology.workspace-'.$activeTab) + @elseif ($moduleKey === 'infusion' && in_array($activeTab, ['overview', 'monitoring'], true)) + @include('care.specialty.infusion.workspace-'.$activeTab) @elseif ($activeTab === 'billing')

diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index 1a191a1..1098e67 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -70,6 +70,15 @@ @if ($moduleKey === 'surgery') Reports @endif + @if ($moduleKey === 'vaccination') + Reports + @endif + @if ($moduleKey === 'pathology') + Reports + @endif + @if ($moduleKey === 'infusion') + Reports + @endif History @if ($canManageClinical ?? $canManageSpecialty ?? true) Billing diff --git a/resources/views/care/specialty/vaccination/print.blade.php b/resources/views/care/specialty/vaccination/print.blade.php new file mode 100644 index 0000000..a15253b --- /dev/null +++ b/resources/views/care/specialty/vaccination/print.blade.php @@ -0,0 +1,31 @@ + + + + + Vaccination summary · {{ $patient->fullName() }} + + + +

Back

+

{{ $patient->fullName() }}

+

{{ $patient->patient_number }} · Visit #{{ $visit->id }} · Stage {{ $visit->specialty_stage ?? '—' }}

+

Eligibility

+ @if ($eligibility)
Vaccine
{{ $eligibility->payload['vaccine'] ?? '—' }}
Cleared
{{ ! empty($eligibility->payload['cleared']) ? 'Yes' : 'No' }}
@else

No eligibility recorded.

@endif +

Consent

+ @if ($consent)
Consent
{{ ! empty($consent->payload['consent_obtained']) ? 'Obtained' : '—' }}
Type
{{ $consent->payload['consent_type'] ?? '—' }}
@else

No consent recorded.

@endif +

Administration

+ @if ($admin)
Vaccine
{{ $admin->payload['vaccine'] ?? '—' }}
Lot
{{ $admin->payload['batch'] ?? '—' }}
Site
{{ $admin->payload['site'] ?? '—' }}
@else

No administration recorded.

@endif +

Observation / AEFI

+ @if ($observation)
AEFI
{{ $observation->payload['aefi'] ?? '—' }}
Outcome
{{ $observation->payload['outcome'] ?? '—' }}
@else

No observation recorded.

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

Vaccination reports

+

Arrivals, open observation notes, vaccines, AEFI, length of stay, and vaccination revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

Observation open

+

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

+
+
+

Avg LOS (range)

+

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

+
+
+ +
+
+

Vaccines administered

+
    + @forelse ($report['vaccine_breakdown'] as $row) +
  • + {{ $row['vaccine'] }} + {{ $row['total'] }} +
  • + @empty +
  • No administrations in range.
  • + @endforelse +
+
+
+

AEFI

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

Vaccination service revenue

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

Observation / AEFI

+

Completed observation can close the vaccination 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 observation recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/vaccination/workspace-overview.blade.php b/resources/views/care/specialty/vaccination/workspace-overview.blade.php new file mode 100644 index 0000000..b89d4f1 --- /dev/null +++ b/resources/views/care/specialty/vaccination/workspace-overview.blade.php @@ -0,0 +1,47 @@ +@php + $elig = $vaccinationEligibility?->payload ?? []; + $admin = $vaccinationAdmin?->payload ?? []; + $obs = $vaccinationObservation?->payload ?? []; +@endphp + +
+
+
+

Vaccination overview

+

Eligibility, consent, administration, and AEFI observation for this episode.

+
+ +
+ +
+
+

Vaccine

+

{{ $admin['vaccine'] ?? ($elig['vaccine'] ?? '—') }}

+

Lot {{ $admin['batch'] ?? '—' }}

+
+
+

Cleared

+

{{ ! empty($elig['cleared']) ? 'Yes' : '—' }}

+

{{ $elig['schedule'] ?? '—' }}

+
+
+

AEFI / disposition

+

{{ $obs['aefi'] ?? '—' }}

+

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

+
+
+ + @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

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

+ @endforeach +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 67488b0..dd98bb7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -53,6 +53,9 @@ use App\Http\Controllers\Care\EntWorkspaceController; use App\Http\Controllers\Care\OncologyWorkspaceController; use App\Http\Controllers\Care\RenalWorkspaceController; use App\Http\Controllers\Care\SurgeryWorkspaceController; +use App\Http\Controllers\Care\VaccinationWorkspaceController; +use App\Http\Controllers\Care\PathologyWorkspaceController; +use App\Http\Controllers\Care\InfusionWorkspaceController; use App\Http\Controllers\Care\SpecialtyModuleController; use App\Http\Controllers\Care\VisitWorkflowController; use App\Http\Controllers\NotificationController; @@ -147,6 +150,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/specialty/oncology/reports', [OncologyWorkspaceController::class, 'reports'])->name('care.specialty.oncology.reports'); Route::get('/specialty/renal/reports', [RenalWorkspaceController::class, 'reports'])->name('care.specialty.renal.reports'); Route::get('/specialty/surgery/reports', [SurgeryWorkspaceController::class, 'reports'])->name('care.specialty.surgery.reports'); + Route::get('/specialty/vaccination/reports', [VaccinationWorkspaceController::class, 'reports'])->name('care.specialty.vaccination.reports'); + Route::get('/specialty/pathology/reports', [PathologyWorkspaceController::class, 'reports'])->name('care.specialty.pathology.reports'); + Route::get('/specialty/infusion/reports', [InfusionWorkspaceController::class, 'reports'])->name('care.specialty.infusion.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'); @@ -216,6 +222,15 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/specialty/surgery/workspace/{visit}/stage', [SurgeryWorkspaceController::class, 'setStage'])->name('care.specialty.surgery.stage'); Route::post('/specialty/surgery/workspace/{visit}/postop', [SurgeryWorkspaceController::class, 'savePostop'])->name('care.specialty.surgery.postop'); Route::get('/specialty/surgery/workspace/{visit}/print', [SurgeryWorkspaceController::class, 'printSummary'])->name('care.specialty.surgery.print'); + Route::post('/specialty/vaccination/workspace/{visit}/stage', [VaccinationWorkspaceController::class, 'setStage'])->name('care.specialty.vaccination.stage'); + Route::post('/specialty/vaccination/workspace/{visit}/observation', [VaccinationWorkspaceController::class, 'saveObservation'])->name('care.specialty.vaccination.observation'); + Route::get('/specialty/vaccination/workspace/{visit}/print', [VaccinationWorkspaceController::class, 'printSummary'])->name('care.specialty.vaccination.print'); + Route::post('/specialty/pathology/workspace/{visit}/stage', [PathologyWorkspaceController::class, 'setStage'])->name('care.specialty.pathology.stage'); + Route::post('/specialty/pathology/workspace/{visit}/verification', [PathologyWorkspaceController::class, 'saveVerification'])->name('care.specialty.pathology.verification'); + Route::get('/specialty/pathology/workspace/{visit}/print', [PathologyWorkspaceController::class, 'printSummary'])->name('care.specialty.pathology.print'); + Route::post('/specialty/infusion/workspace/{visit}/stage', [InfusionWorkspaceController::class, 'setStage'])->name('care.specialty.infusion.stage'); + Route::post('/specialty/infusion/workspace/{visit}/monitoring', [InfusionWorkspaceController::class, 'saveMonitoring'])->name('care.specialty.infusion.monitoring'); + Route::get('/specialty/infusion/workspace/{visit}/print', [InfusionWorkspaceController::class, 'printSummary'])->name('care.specialty.infusion.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/CareInfusionSuiteTest.php b/tests/Feature/CareInfusionSuiteTest.php new file mode 100644 index 0000000..9506d5d --- /dev/null +++ b/tests/Feature/CareInfusionSuiteTest.php @@ -0,0 +1,230 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'inf-owner', + 'name' => 'Owner', + 'email' => 'inf-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Infusion Clinic', + 'slug' => 'inf-owner-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, + 'infusion', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'infusion') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-INF-SUITE', + 'first_name' => 'Efua', + 'last_name' => 'Infusion', + 'gender' => 'female', + 'date_of_birth' => '1992-11-03', + ]); + + $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_clinical_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'infusion', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Infusion overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Start assessment') + ->assertSee('Infusion reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'infusion', + 'visit' => $this->visit, + 'tab' => 'assessment', + ])) + ->assertOk() + ->assertSee('Fit for infusion'); + } + + public function test_clinical_save_sets_stage_and_alerts(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'infusion', + 'visit' => $this->visit, + ]), [ + 'tab' => 'assessment', + 'payload' => [ + 'indication' => 'IV iron', + 'history' => 'Symptomatic anaemia', + 'fit_for_infusion' => '0', + ], + ]) + ->assertRedirect(); + + $this->assertSame('assessment', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('inf.not_fit', $codes); + } + + public function test_stage_advance_and_terminal_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.infusion.stage', $this->visit), [ + 'stage' => 'monitoring', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'infusion', + 'visit' => $this->visit, + 'tab' => 'monitoring', + ])); + + $this->assertSame('monitoring', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.infusion.monitoring', $this->visit), [ + 'tab' => 'monitoring', + 'payload' => [ + 'post_vitals' => 'Stable', + 'reaction' => 'None', + 'advice' => 'Return if fever', + 'outcome' => 'Completed — discharged', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'infusion', + 'visit' => $this->visit, + 'tab' => 'monitoring', + ])); + + $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' => 'infusion_monitoring', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.infusion.reports')) + ->assertOk() + ->assertSee('Infusion reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.infusion.print', $this->visit)) + ->assertOk() + ->assertSee($this->patient->fullName()); + } + + public function test_list_index_does_not_auto_open_patient(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.show', 'infusion')) + ->assertOk() + ->assertDontSee('Infusion overview'); + } +} diff --git a/tests/Feature/CarePathologySuiteTest.php b/tests/Feature/CarePathologySuiteTest.php new file mode 100644 index 0000000..d79ab4c --- /dev/null +++ b/tests/Feature/CarePathologySuiteTest.php @@ -0,0 +1,232 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'path-owner', + 'name' => 'Owner', + 'email' => 'path-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Pathology Clinic', + 'slug' => 'path-owner-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, + 'pathology', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'pathology') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-PATH-SUITE', + 'first_name' => 'Ama', + 'last_name' => 'Biopsy', + 'gender' => 'female', + 'date_of_birth' => '1985-08-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_clinical_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'pathology', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Pathology overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Start request') + ->assertSee('Pathology reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'pathology', + 'visit' => $this->visit, + 'tab' => 'request', + ])) + ->assertOk() + ->assertSee('Specimen type'); + } + + public function test_clinical_save_sets_stage_and_alerts(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'pathology', + 'visit' => $this->visit, + ]), [ + 'tab' => 'request', + 'payload' => [ + 'specimen_type' => 'Breast core biopsy', + 'site' => 'Left breast', + 'clinical_history' => 'Palpable mass', + 'urgency' => 'Urgent', + 'tests_requested' => 'Histo', + ], + ]) + ->assertRedirect(); + + $this->assertSame('request', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('path.urgent', $codes); + } + + public function test_stage_advance_and_terminal_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.pathology.stage', $this->visit), [ + 'stage' => 'verified', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'pathology', + 'visit' => $this->visit, + 'tab' => 'verification', + ])); + + $this->assertSame('verified', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.pathology.verification', $this->visit), [ + 'tab' => 'verification', + 'payload' => [ + 'verified' => '1', + 'verified_by' => 'Dr Path', + 'released' => '1', + 'outcome' => 'Completed — released', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'pathology', + 'visit' => $this->visit, + 'tab' => 'verification', + ])); + + $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' => 'path_verification', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.pathology.reports')) + ->assertOk() + ->assertSee('Pathology reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.pathology.print', $this->visit)) + ->assertOk() + ->assertSee($this->patient->fullName()); + } + + public function test_list_index_does_not_auto_open_patient(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.show', 'pathology')) + ->assertOk() + ->assertDontSee('Pathology overview'); + } +} diff --git a/tests/Feature/CareVaccinationSuiteTest.php b/tests/Feature/CareVaccinationSuiteTest.php new file mode 100644 index 0000000..a631e97 --- /dev/null +++ b/tests/Feature/CareVaccinationSuiteTest.php @@ -0,0 +1,233 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'vax-owner', + 'name' => 'Owner', + 'email' => 'vax-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Vaccination Clinic', + 'slug' => 'vax-owner-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, + 'vaccination', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'vaccination') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-VAX-SUITE', + 'first_name' => 'Kwame', + 'last_name' => 'Vaccine', + 'gender' => 'male', + 'date_of_birth' => '1990-04-12', + ]); + + $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_clinical_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'vaccination', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Vaccination overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Start eligibility') + ->assertSee('Vaccination reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'vaccination', + 'visit' => $this->visit, + 'tab' => 'eligibility', + ])) + ->assertOk() + ->assertSee('Cleared to vaccinate'); + } + + public function test_clinical_save_sets_stage_and_alerts(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'vaccination', + 'visit' => $this->visit, + ]), [ + 'tab' => 'eligibility', + 'payload' => [ + 'vaccine' => 'Yellow fever', + 'schedule' => 'Travel', + 'contraindications' => '1', + 'fever_today' => '1', + 'cleared' => '0', + ], + ]) + ->assertRedirect(); + + $this->assertSame('eligibility', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('vax.not_cleared', $codes); + } + + public function test_stage_advance_and_terminal_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.vaccination.stage', $this->visit), [ + 'stage' => 'observation', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'vaccination', + 'visit' => $this->visit, + 'tab' => 'observation', + ])); + + $this->assertSame('observation', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.vaccination.observation', $this->visit), [ + 'tab' => 'observation', + 'payload' => [ + 'observation_minutes' => '15', + 'vitals' => 'Stable', + 'aefi' => 'None', + 'advice' => 'Paracetamol PRN', + 'outcome' => 'Completed — discharged', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'vaccination', + 'visit' => $this->visit, + 'tab' => 'observation', + ])); + + $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' => 'vax_observation', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.vaccination.reports')) + ->assertOk() + ->assertSee('Vaccination reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.vaccination.print', $this->visit)) + ->assertOk() + ->assertSee($this->patient->fullName()); + } + + public function test_list_index_does_not_auto_open_patient(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.show', 'vaccination')) + ->assertOk() + ->assertDontSee('Vaccination overview'); + } +}