From b7e5a7d9ad0f9526f125e5feaa56ab088c1b211f Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sun, 19 Jul 2026 20:16:18 +0000 Subject: [PATCH] Add full Oncology, Renal Care, and Surgery specialty clinical suites. Match Pediatrics/ENT depth with stages, workspace tabs, clinical records, reports/print, demo seed, and suite tests. Co-authored-by: Cursor --- .../Care/OncologyWorkspaceController.php | 215 ++++++++++++++++ .../Care/RenalWorkspaceController.php | 214 ++++++++++++++++ .../Care/SpecialtyModuleController.php | 181 ++++++++++++++ .../Care/SurgeryWorkspaceController.php | 216 ++++++++++++++++ app/Services/Care/DemoTenantSeeder.php | 223 +++++++++++++++++ .../Oncology/OncologyAnalyticsService.php | 163 ++++++++++++ .../Care/Oncology/OncologyWorkflowService.php | 106 ++++++++ .../Care/Renal/RenalAnalyticsService.php | 161 ++++++++++++ .../Care/Renal/RenalWorkflowService.php | 98 ++++++++ .../Care/SpecialtyClinicalRecordService.php | 33 +++ app/Services/Care/SpecialtyShellService.php | 14 +- .../Care/Surgery/SurgeryAnalyticsService.php | 161 ++++++++++++ .../Care/Surgery/SurgeryWorkflowService.php | 119 +++++++++ config/care_specialty_clinical.php | 160 +++++++++--- config/care_specialty_shell.php | 80 ++++-- .../care/specialty/oncology/print.blade.php | 33 +++ .../care/specialty/oncology/reports.blade.php | 87 +++++++ .../oncology/workspace-overview.blade.php | 60 +++++ .../oncology/workspace-treatment.blade.php | 70 ++++++ .../care/specialty/renal/print.blade.php | 12 + .../care/specialty/renal/reports.blade.php | 48 ++++ .../renal/workspace-overview.blade.php | 46 ++++ .../specialty/renal/workspace-plan.blade.php | 67 +++++ .../specialty/sections/workspace.blade.php | 6 + .../views/care/specialty/shell.blade.php | 9 + .../care/specialty/surgery/print.blade.php | 14 ++ .../care/specialty/surgery/reports.blade.php | 33 +++ .../surgery/workspace-overview.blade.php | 46 ++++ .../surgery/workspace-postop.blade.php | 67 +++++ routes/web.php | 15 ++ tests/Feature/CareOncologySuiteTest.php | 233 ++++++++++++++++++ tests/Feature/CareRenalSuiteTest.php | 231 +++++++++++++++++ tests/Feature/CareSurgerySuiteTest.php | 224 +++++++++++++++++ 33 files changed, 3392 insertions(+), 53 deletions(-) create mode 100644 app/Http/Controllers/Care/OncologyWorkspaceController.php create mode 100644 app/Http/Controllers/Care/RenalWorkspaceController.php create mode 100644 app/Http/Controllers/Care/SurgeryWorkspaceController.php create mode 100644 app/Services/Care/Oncology/OncologyAnalyticsService.php create mode 100644 app/Services/Care/Oncology/OncologyWorkflowService.php create mode 100644 app/Services/Care/Renal/RenalAnalyticsService.php create mode 100644 app/Services/Care/Renal/RenalWorkflowService.php create mode 100644 app/Services/Care/Surgery/SurgeryAnalyticsService.php create mode 100644 app/Services/Care/Surgery/SurgeryWorkflowService.php create mode 100644 resources/views/care/specialty/oncology/print.blade.php create mode 100644 resources/views/care/specialty/oncology/reports.blade.php create mode 100644 resources/views/care/specialty/oncology/workspace-overview.blade.php create mode 100644 resources/views/care/specialty/oncology/workspace-treatment.blade.php create mode 100644 resources/views/care/specialty/renal/print.blade.php create mode 100644 resources/views/care/specialty/renal/reports.blade.php create mode 100644 resources/views/care/specialty/renal/workspace-overview.blade.php create mode 100644 resources/views/care/specialty/renal/workspace-plan.blade.php create mode 100644 resources/views/care/specialty/surgery/print.blade.php create mode 100644 resources/views/care/specialty/surgery/reports.blade.php create mode 100644 resources/views/care/specialty/surgery/workspace-overview.blade.php create mode 100644 resources/views/care/specialty/surgery/workspace-postop.blade.php create mode 100644 tests/Feature/CareOncologySuiteTest.php create mode 100644 tests/Feature/CareRenalSuiteTest.php create mode 100644 tests/Feature/CareSurgerySuiteTest.php diff --git a/app/Http/Controllers/Care/OncologyWorkspaceController.php b/app/Http/Controllers/Care/OncologyWorkspaceController.php new file mode 100644 index 0000000..b7a7ce3 --- /dev/null +++ b/app/Http/Controllers/Care/OncologyWorkspaceController.php @@ -0,0 +1,215 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'oncology'), 403); + } + + protected function assertOncologyManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'oncology'), 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->assertOncologyManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'oncology', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'oncology', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('oncology', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveTreatment( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + OncologyWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertOncologyManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('oncology', 'onc_treatment') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'treatment']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('oncology', 'onc_treatment'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'oncology', + 'onc_treatment', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + OncologyWorkflowService::STAGE_TREATMENT, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'oncology', + OncologyWorkflowService::STAGE_TREATMENT, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'oncology', + OncologyWorkflowService::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' => 'oncology', 'visit' => $visit, 'tab' => 'treatment']) + ->with('success', 'Oncology treatment saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + OncologyAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertOncologyAccess($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.oncology.reports', [ + 'moduleKey' => 'oncology', + 'definition' => $modules->definition('oncology'), + 'shellNav' => $shell->navItems('oncology'), + '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->assertOncologyAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.oncology.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'history' => $clinical->findForVisit($visit, 'oncology', 'onc_history'), + 'staging' => $clinical->findForVisit($visit, 'oncology', 'onc_staging'), + 'investigation' => $clinical->findForVisit($visit, 'oncology', 'onc_investigation'), + 'plan' => $clinical->findForVisit($visit, 'oncology', 'onc_plan'), + 'treatment' => $clinical->findForVisit($visit, 'oncology', 'onc_treatment'), + ]); + } +} diff --git a/app/Http/Controllers/Care/RenalWorkspaceController.php b/app/Http/Controllers/Care/RenalWorkspaceController.php new file mode 100644 index 0000000..2eff230 --- /dev/null +++ b/app/Http/Controllers/Care/RenalWorkspaceController.php @@ -0,0 +1,214 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'renal'), 403); + } + + protected function assertRenalManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'renal'), 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->assertRenalManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'renal', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'renal', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('renal', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function savePlan( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + RenalWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertRenalManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('renal', 'renal_plan') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'plan']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('renal', 'renal_plan'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'renal', + 'renal_plan', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + RenalWorkflowService::STAGE_PLAN, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'renal', + RenalWorkflowService::STAGE_PLAN, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'renal', + RenalWorkflowService::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' => 'renal', 'visit' => $visit, 'tab' => 'plan']) + ->with('success', 'Renal care plan saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + RenalAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertRenalAccess($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.renal.reports', [ + 'moduleKey' => 'renal', + 'definition' => $modules->definition('renal'), + 'shellNav' => $shell->navItems('renal'), + '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->assertRenalAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.renal.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'history' => $clinical->findForVisit($visit, 'renal', 'renal_history'), + 'exam' => $clinical->findForVisit($visit, 'renal', 'renal_exam'), + 'dialysis' => $clinical->findForVisit($visit, 'renal', 'renal_session'), + 'plan' => $clinical->findForVisit($visit, 'renal', 'renal_plan'), + ]); + } +} diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index aac4efe..b4b5fb7 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -162,6 +162,9 @@ class SpecialtyModuleController extends Controller 'pediatrics' => 'history', 'orthopedics' => 'history', 'ent' => 'history', + 'oncology' => 'history', + 'renal' => 'history', + 'surgery' => 'history', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -242,6 +245,9 @@ class SpecialtyModuleController extends Controller 'pediatrics' => 'history', 'orthopedics' => 'history', 'ent' => 'history', + 'oncology' => 'history', + 'renal' => 'history', + 'surgery' => 'history', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -806,6 +812,109 @@ class SpecialtyModuleController extends Controller } } + + if ($module === 'oncology' && in_array($recordType, ['onc_history', 'onc_staging', 'onc_investigation', 'onc_plan'], true)) { + $workflow = app(\App\Services\Care\Oncology\OncologyWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'onc_history' => $workflow->stageFromHistory($record->payload ?? []), + 'onc_staging' => $workflow->stageFromStaging($record->payload ?? []), + 'onc_investigation' => $workflow->stageFromInvestigation($record->payload ?? []), + 'onc_plan' => $workflow->stageFromPlan($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'history' => 1, + 'staging' => 2, + 'investigation' => 3, + 'plan' => 4, + 'treatment' => 5, + 'completed' => 6, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage($organization, $visit, 'oncology', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage($organization, $visit, 'oncology', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } + } + + if ($module === 'renal' && in_array($recordType, ['renal_history', 'renal_exam', 'renal_session'], true)) { + $workflow = app(\App\Services\Care\Renal\RenalWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'renal_history' => $workflow->stageFromHistory($record->payload ?? []), + 'renal_exam' => $workflow->stageFromExam($record->payload ?? []), + 'renal_session' => $workflow->stageFromDialysis($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'history' => 1, + 'exam' => 2, + 'dialysis' => 3, + 'plan' => 4, + 'completed' => 5, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage($organization, $visit, 'renal', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage($organization, $visit, 'renal', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } + } + + if ($module === 'surgery' && in_array($recordType, ['surg_history', 'surg_exam', 'surg_investigation', 'surg_plan', 'surg_procedure'], true)) { + $workflow = app(\App\Services\Care\Surgery\SurgeryWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'surg_history' => $workflow->stageFromHistory($record->payload ?? []), + 'surg_exam' => $workflow->stageFromExam($record->payload ?? []), + 'surg_investigation' => $workflow->stageFromInvestigation($record->payload ?? []), + 'surg_plan' => $workflow->stageFromPlan($record->payload ?? []), + 'surg_procedure' => $workflow->stageFromProcedure($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'history' => 1, + 'exam' => 2, + 'investigation' => 3, + 'plan' => 4, + 'procedure' => 5, + 'postop' => 6, + 'completed' => 7, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage($organization, $visit, 'surgery', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage($organization, $visit, 'surgery', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } + } + return redirect() ->route('care.specialty.workspace', [ 'module' => $module, @@ -1262,6 +1371,27 @@ class SpecialtyModuleController extends Controller $entProcedure = null; $entStageCodes = []; $entStageFlow = []; + $oncologyHistory = null; + $oncologyStaging = null; + $oncologyInvestigation = null; + $oncologyPlan = null; + $oncologyTreatment = null; + $oncologyStageCodes = []; + $oncologyStageFlow = []; + $renalHistory = null; + $renalExam = null; + $renalDialysis = null; + $renalPlan = null; + $renalStageCodes = []; + $renalStageFlow = []; + $surgeryHistory = null; + $surgeryExam = null; + $surgeryInvestigation = null; + $surgeryPlan = null; + $surgeryProcedure = null; + $surgeryPostop = null; + $surgeryStageCodes = []; + $surgeryStageFlow = []; $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); $clinical = app(SpecialtyClinicalRecordService::class); @@ -1456,6 +1586,36 @@ class SpecialtyModuleController extends Controller $entStageFlow = app(\App\Services\Care\Ent\EntWorkflowService::class)->stageFlow(); } + if ($module === 'oncology') { + $oncologyHistory = $clinical->findForVisit($workspaceVisit, 'oncology', 'onc_history'); + $oncologyStaging = $clinical->findForVisit($workspaceVisit, 'oncology', 'onc_staging'); + $oncologyInvestigation = $clinical->findForVisit($workspaceVisit, 'oncology', 'onc_investigation'); + $oncologyPlan = $clinical->findForVisit($workspaceVisit, 'oncology', 'onc_plan'); + $oncologyTreatment = $clinical->findForVisit($workspaceVisit, 'oncology', 'onc_treatment'); + $oncologyStageCodes = collect($shell->stages('oncology'))->pluck('code')->all(); + $oncologyStageFlow = app(\App\Services\Care\Oncology\OncologyWorkflowService::class)->stageFlow(); + } + + if ($module === 'renal') { + $renalHistory = $clinical->findForVisit($workspaceVisit, 'renal', 'renal_history'); + $renalExam = $clinical->findForVisit($workspaceVisit, 'renal', 'renal_exam'); + $renalDialysis = $clinical->findForVisit($workspaceVisit, 'renal', 'renal_session'); + $renalPlan = $clinical->findForVisit($workspaceVisit, 'renal', 'renal_plan'); + $renalStageCodes = collect($shell->stages('renal'))->pluck('code')->all(); + $renalStageFlow = app(\App\Services\Care\Renal\RenalWorkflowService::class)->stageFlow(); + } + + if ($module === 'surgery') { + $surgeryHistory = $clinical->findForVisit($workspaceVisit, 'surgery', 'surg_history'); + $surgeryExam = $clinical->findForVisit($workspaceVisit, 'surgery', 'surg_exam'); + $surgeryInvestigation = $clinical->findForVisit($workspaceVisit, 'surgery', 'surg_investigation'); + $surgeryPlan = $clinical->findForVisit($workspaceVisit, 'surgery', 'surg_plan'); + $surgeryProcedure = $clinical->findForVisit($workspaceVisit, 'surgery', 'surg_procedure'); + $surgeryPostop = $clinical->findForVisit($workspaceVisit, 'surgery', 'surg_postop'); + $surgeryStageCodes = collect($shell->stages('surgery'))->pluck('code')->all(); + $surgeryStageFlow = app(\App\Services\Care\Surgery\SurgeryWorkflowService::class)->stageFlow(); + } + if ($patientHeader !== null) { $patientHeader['clinical_alerts'] = $clinicalAlerts; } @@ -1604,6 +1764,27 @@ class SpecialtyModuleController extends Controller 'entProcedure' => $entProcedure, 'entStageCodes' => $entStageCodes, 'entStageFlow' => $entStageFlow, + 'oncologyHistory' => $oncologyHistory, + 'oncologyStaging' => $oncologyStaging, + 'oncologyInvestigation' => $oncologyInvestigation, + 'oncologyPlan' => $oncologyPlan, + 'oncologyTreatment' => $oncologyTreatment, + 'oncologyStageCodes' => $oncologyStageCodes, + 'oncologyStageFlow' => $oncologyStageFlow, + 'renalHistory' => $renalHistory, + 'renalExam' => $renalExam, + 'renalDialysis' => $renalDialysis, + 'renalPlan' => $renalPlan, + 'renalStageCodes' => $renalStageCodes, + 'renalStageFlow' => $renalStageFlow, + 'surgeryHistory' => $surgeryHistory, + 'surgeryExam' => $surgeryExam, + 'surgeryInvestigation' => $surgeryInvestigation, + 'surgeryPlan' => $surgeryPlan, + 'surgeryProcedure' => $surgeryProcedure, + 'surgeryPostop' => $surgeryPostop, + 'surgeryStageCodes' => $surgeryStageCodes, + 'surgeryStageFlow' => $surgeryStageFlow, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'branchLabel' => $branchLabel, diff --git a/app/Http/Controllers/Care/SurgeryWorkspaceController.php b/app/Http/Controllers/Care/SurgeryWorkspaceController.php new file mode 100644 index 0000000..c213a1f --- /dev/null +++ b/app/Http/Controllers/Care/SurgeryWorkspaceController.php @@ -0,0 +1,216 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'surgery'), 403); + } + + protected function assertSurgeryManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'surgery'), 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->assertSurgeryManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'surgery', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'surgery', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('surgery', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function savePostop( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + SurgeryWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertSurgeryManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('surgery', 'surg_postop') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'postop']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('surgery', 'surg_postop'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'surgery', + 'surg_postop', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + SurgeryWorkflowService::STAGE_POSTOP, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'surgery', + SurgeryWorkflowService::STAGE_POSTOP, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'surgery', + SurgeryWorkflowService::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' => 'surgery', 'visit' => $visit, 'tab' => 'postop']) + ->with('success', 'Post-op note saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + SurgeryAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertSurgeryAccess($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.surgery.reports', [ + 'moduleKey' => 'surgery', + 'definition' => $modules->definition('surgery'), + 'shellNav' => $shell->navItems('surgery'), + '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->assertSurgeryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.surgery.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'history' => $clinical->findForVisit($visit, 'surgery', 'surg_history'), + 'exam' => $clinical->findForVisit($visit, 'surgery', 'surg_exam'), + 'investigation' => $clinical->findForVisit($visit, 'surgery', 'surg_investigation'), + 'plan' => $clinical->findForVisit($visit, 'surgery', 'surg_plan'), + 'procedure' => $clinical->findForVisit($visit, 'surgery', 'surg_procedure'), + 'postop' => $clinical->findForVisit($visit, 'surgery', 'surg_postop'), + ]); + } +} diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index a00ff5e..970087f 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -1023,6 +1023,9 @@ class DemoTenantSeeder $this->seedPediatricsClinicalDemo($organization, $ownerRef); $this->seedOrthopedicsClinicalDemo($organization, $ownerRef); $this->seedEntClinicalDemo($organization, $ownerRef); + $this->seedOncologyClinicalDemo($organization, $ownerRef); + $this->seedRenalClinicalDemo($organization, $ownerRef); + $this->seedSurgeryClinicalDemo($organization, $ownerRef); return $count; } @@ -2222,6 +2225,226 @@ class DemoTenantSeeder } } + + private function seedOncologyClinicalDemo(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', 'oncology') + ->where('is_active', true) + ->pluck('id'); + if ($departmentIds->isEmpty()) { + return; + } + $appointments = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('department_id', $departmentIds) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->whereNotNull('visit_id') + ->with('visit') + ->orderBy('branch_id') + ->orderBy('id') + ->get(); + if ($appointments->isEmpty()) { + return; + } + $clinical = app(SpecialtyClinicalRecordService::class); + $index = 0; + foreach ($appointments as $appointment) { + $visit = $appointment->visit; + if (! $visit) { + continue; + } + $highRisk = $index === 0; + $clinical->upsert($organization, $visit, 'oncology', 'onc_history', [ + 'history' => $highRisk + ? 'New diagnosis work-up; progressive fatigue and weight loss' + : 'On adjuvant regimen; here for cycle review', + 'cancer_history' => $highRisk ? 'Breast mass biopsied last month' : 'Stage II breast Ca — surgery 4 months ago', + 'medications' => $highRisk ? 'Analgesia PRN' : 'Current chemo cycle medications', + 'allergies' => 'NKDA', + 'comorbidities' => $highRisk ? 'Hypertension' : 'None significant', + 'social' => 'Family support available', + ], $ownerRef, $ownerRef, 'history'); + $clinical->upsert($organization, $visit, 'oncology', 'onc_staging', [ + 'diagnosis' => $highRisk ? 'Suspected metastatic breast carcinoma' : 'Breast carcinoma — adjuvant', + 'stage' => $highRisk ? 'cT4 N2 M1' : 'pT2 N1 M0', + 'histology' => 'Invasive ductal carcinoma', + 'performance_status' => $highRisk ? '3' : '1', + 'exam_findings' => $highRisk ? 'Cachexia; axillary nodes; hepatic tenderness' : 'Well; surgical scar healed', + 'sites' => $highRisk ? 'Breast, axilla, liver' : 'None residual known', + ], $ownerRef, $ownerRef, 'staging'); + if ($highRisk) { + $clinical->upsert($organization, $visit, 'oncology', 'onc_plan', [ + 'diagnosis' => 'Metastatic breast carcinoma', + 'plan' => 'Staging imaging; MDT; supportive care; consider palliative systemic therapy', + 'intent' => 'Palliative', + 'medications' => 'Analgesia; antiemetics PRN', + 'treatment_planned' => true, + 'follow_up' => 'Chemo day-care after MDT', + 'advice' => 'Return if fever or uncontrolled pain', + ], $ownerRef, $ownerRef, 'plan'); + } + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $highRisk ? 'plan' : 'staging']); + } + $index++; + } + } + + private function seedRenalClinicalDemo(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', 'renal') + ->where('is_active', true) + ->pluck('id'); + if ($departmentIds->isEmpty()) { + return; + } + $appointments = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('department_id', $departmentIds) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->whereNotNull('visit_id') + ->with('visit') + ->orderBy('branch_id') + ->orderBy('id') + ->get(); + if ($appointments->isEmpty()) { + return; + } + $clinical = app(SpecialtyClinicalRecordService::class); + $index = 0; + foreach ($appointments as $appointment) { + $visit = $appointment->visit; + if (! $visit) { + continue; + } + $highRisk = $index === 0; + $clinical->upsert($organization, $visit, 'renal', 'renal_history', [ + 'history' => $highRisk + ? 'Missed last dialysis; progressive dyspnoea and oedema' + : 'Routine thrice-weekly haemodialysis review', + 'ckd_stage' => $highRisk ? 'CKD 5D — 2 years on HD' : 'CKD 5D — 8 months on HD', + 'access_history' => 'Left AV fistula', + 'medications' => 'EPO; phosphate binders; antihypertensives', + 'allergies' => 'NKDA', + 'comorbidities' => $highRisk ? 'Diabetes; heart failure' : 'Hypertension', + ], $ownerRef, $ownerRef, 'history'); + $clinical->upsert($organization, $visit, 'renal', 'renal_exam', [ + 'chief_complaint' => $highRisk ? 'Fluid overload / missed dialysis' : 'Routine dialysis session', + 'bp' => $highRisk ? '168/98' : '132/78', + 'weight_kg' => $highRisk ? 78.5 : 65.2, + 'fluid_status' => $highRisk ? 'Overload' : 'Euvolemic', + 'access' => 'Left AVF thrills present', + 'exam_findings' => $highRisk ? 'Bilateral basal crackles; sacral oedema' : 'Chest clear; no oedema', + 'uremic_symptoms' => $highRisk ? 'Nausea; pruritus' : 'None', + ], $ownerRef, $ownerRef, 'exam'); + if ($highRisk) { + $clinical->upsert($organization, $visit, 'renal', 'renal_session', [ + 'session_type' => 'Hemodialysis', + 'pre_weight_kg' => 78.5, + 'uf_goal_l' => 3.5, + 'access' => 'Left AVF', + 'bp_pre' => '168/98', + 'labs_summary' => 'K 5.8; Cr elevated; Hb 9.1', + 'complications' => 'Hypotension mid-session anticipated', + 'notes' => 'Urgent HD for fluid overload after missed session', + ], $ownerRef, $ownerRef, 'dialysis'); + } + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $highRisk ? 'dialysis' : 'exam']); + } + $index++; + } + } + + private function seedSurgeryClinicalDemo(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', 'surgery') + ->where('is_active', true) + ->pluck('id'); + if ($departmentIds->isEmpty()) { + return; + } + $appointments = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('department_id', $departmentIds) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->whereNotNull('visit_id') + ->with('visit') + ->orderBy('branch_id') + ->orderBy('id') + ->get(); + if ($appointments->isEmpty()) { + return; + } + $clinical = app(SpecialtyClinicalRecordService::class); + $index = 0; + foreach ($appointments as $appointment) { + $visit = $appointment->visit; + if (! $visit) { + continue; + } + $highRisk = $index === 0; + $clinical->upsert($organization, $visit, 'surgery', 'surg_history', [ + 'history' => $highRisk + ? 'Right inguinal hernia with intermittent pain; electing repair' + : 'Post-appendicectomy wound check', + 'past_surgical' => $highRisk ? 'None' : 'Laparoscopic appendicectomy 10 days ago', + 'medications' => $highRisk ? 'None' : 'Analgesia; antibiotics completed', + 'allergies' => 'NKDA', + 'comorbidities' => $highRisk ? 'Well-controlled diabetes' : 'None', + 'anaesthesia_history' => 'No prior issues', + ], $ownerRef, $ownerRef, 'history'); + $clinical->upsert($organization, $visit, 'surgery', 'surg_exam', [ + 'chief_complaint' => $highRisk ? 'Inguinal hernia — pre-op' : 'Wound review', + 'exam_findings' => $highRisk + ? 'Right reducible inguinal hernia; no strangulation' + : 'Wound clean; mild erythema; no discharge', + 'asa_class' => $highRisk ? 'II' : 'I', + 'fitness' => 'Fit for day-case anaesthesia', + 'site_marking' => $highRisk, + ], $ownerRef, $ownerRef, 'exam'); + if ($highRisk) { + $clinical->upsert($organization, $visit, 'surgery', 'surg_plan', [ + 'diagnosis' => 'Right inguinal hernia', + 'proposed_procedure' => 'Open mesh hernia repair', + 'plan' => 'Day-case repair; consent discussed including mesh risks', + 'consent_obtained' => true, + 'procedure_planned' => true, + 'follow_up' => 'Wound check 7–10 days', + 'advice' => 'NPO from midnight; bring meds list', + ], $ownerRef, $ownerRef, 'plan'); + } + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']); + } + $index++; + } + } + private function seedDentistryClinicalDemo(Organization $organization, string $ownerRef): void { // Specialty departments are provisioned without organization_id; scope via branches. diff --git a/app/Services/Care/Oncology/OncologyAnalyticsService.php b/app/Services/Care/Oncology/OncologyAnalyticsService.php new file mode 100644 index 0000000..bd9bc23 --- /dev/null +++ b/app/Services/Care/Oncology/OncologyAnalyticsService.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, 'oncology', $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'); + + $chemoOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'oncology') + ->where('record_type', 'onc_treatment') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->where('status', '!=', SpecialtyClinicalRecord::STATUS_COMPLETED) + ->count(); + + $stagingRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'oncology') + ->where('record_type', 'onc_staging') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $diagnosisBreakdown = $stagingRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['diagnosis'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $diagnosis) => [ + 'diagnosis' => $diagnosis, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $treatmentRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'oncology') + ->where('record_type', 'onc_treatment') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $regimenBreakdown = $treatmentRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['regimen'] ?? ($r->payload['treatment'] ?? 'Unknown'))) + ->map(fn (Collection $rows, string $regimen) => [ + 'regimen' => $regimen, + '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, 'oncology')) + ->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, + 'chemo_open' => $chemoOpen, + 'diagnosis_breakdown' => $diagnosisBreakdown, + 'regimen_breakdown' => $regimenBreakdown, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/Oncology/OncologyWorkflowService.php b/app/Services/Care/Oncology/OncologyWorkflowService.php new file mode 100644 index 0000000..f14a1ba --- /dev/null +++ b/app/Services/Care/Oncology/OncologyWorkflowService.php @@ -0,0 +1,106 @@ + $payload + */ + public function stageFromHistory(array $payload): string + { + $history = trim((string) ($payload['history'] ?? '')); + if ($history !== '') { + return self::STAGE_HISTORY; + } + + return self::STAGE_CHECK_IN; + } + + /** + * @param array $payload + */ + public function stageFromStaging(array $payload): string + { + if (! empty($payload['diagnosis']) + || ! empty($payload['stage']) + || ! empty($payload['exam_findings'])) { + return self::STAGE_STAGING; + } + + return self::STAGE_HISTORY; + } + + /** + * @param array $payload + */ + public function stageFromInvestigation(array $payload): string + { + $findings = trim((string) ($payload['findings'] ?? '')); + if ($findings !== '') { + return self::STAGE_INVESTIGATION; + } + + return self::STAGE_STAGING; + } + + /** + * @param array $payload + */ + public function stageFromPlan(array $payload): string + { + if (! empty($payload['treatment_planned'])) { + return self::STAGE_TREATMENT; + } + + $plan = trim((string) ($payload['plan'] ?? '')); + if ($plan !== '') { + return self::STAGE_PLAN; + } + + return self::STAGE_INVESTIGATION; + } + + /** + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return str_contains($outcome, 'completed'); + } + + /** + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_CHECK_IN => ['next' => self::STAGE_HISTORY, 'label' => 'Start history'], + self::STAGE_HISTORY => ['next' => self::STAGE_STAGING, 'label' => 'Move to staging / exam'], + self::STAGE_STAGING => ['next' => self::STAGE_INVESTIGATION, 'label' => 'Move to investigations'], + self::STAGE_INVESTIGATION => ['next' => self::STAGE_PLAN, 'label' => 'Move to diagnosis & plan'], + self::STAGE_PLAN => ['next' => self::STAGE_TREATMENT, 'label' => 'Move to treatment'], + self::STAGE_TREATMENT => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'], + self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'], + ]; + } +} diff --git a/app/Services/Care/Renal/RenalAnalyticsService.php b/app/Services/Care/Renal/RenalAnalyticsService.php new file mode 100644 index 0000000..28e6587 --- /dev/null +++ b/app/Services/Care/Renal/RenalAnalyticsService.php @@ -0,0 +1,161 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'renal', $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'); + + $dialysisOpen = Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->where('specialty_stage', 'dialysis') + ->count(); + + $sessionRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'renal') + ->where('record_type', 'renal_session') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $sessionBreakdown = $sessionRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['session_type'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $type) => [ + 'session_type' => $type, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $planRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'renal') + ->where('record_type', 'renal_plan') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $diagnosisBreakdown = $planRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['diagnosis'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $diagnosis) => [ + 'diagnosis' => $diagnosis, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $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, 'renal')) + ->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, + 'dialysis_open' => $dialysisOpen, + 'session_breakdown' => $sessionBreakdown, + 'diagnosis_breakdown' => $diagnosisBreakdown, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/Renal/RenalWorkflowService.php b/app/Services/Care/Renal/RenalWorkflowService.php new file mode 100644 index 0000000..837c3c1 --- /dev/null +++ b/app/Services/Care/Renal/RenalWorkflowService.php @@ -0,0 +1,98 @@ + $payload + */ + public function stageFromHistory(array $payload): string + { + $history = trim((string) ($payload['history'] ?? '')); + if ($history !== '') { + return self::STAGE_HISTORY; + } + + return self::STAGE_CHECK_IN; + } + + /** + * @param array $payload + */ + public function stageFromExam(array $payload): string + { + if (! empty($payload['chief_complaint']) + || ! empty($payload['exam_findings']) + || ! empty($payload['fluid_status'])) { + return self::STAGE_EXAM; + } + + return self::STAGE_HISTORY; + } + + /** + * @param array $payload + */ + public function stageFromDialysis(array $payload): string + { + if (! empty($payload['session_type']) || ! empty($payload['notes'])) { + return self::STAGE_DIALYSIS; + } + + return self::STAGE_EXAM; + } + + /** + * @param array $payload + */ + public function stageFromPlan(array $payload): string + { + $plan = trim((string) ($payload['plan'] ?? '')); + if ($plan !== '') { + return self::STAGE_PLAN; + } + + return self::STAGE_DIALYSIS; + } + + /** + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return str_contains($outcome, 'completed') || str_contains($outcome, 'discharged'); + } + + /** + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_CHECK_IN => ['next' => self::STAGE_HISTORY, 'label' => 'Start history'], + self::STAGE_HISTORY => ['next' => self::STAGE_EXAM, 'label' => 'Move to exam'], + self::STAGE_EXAM => ['next' => self::STAGE_DIALYSIS, 'label' => 'Move to dialysis / labs'], + self::STAGE_DIALYSIS => ['next' => self::STAGE_PLAN, 'label' => 'Move to care plan'], + self::STAGE_PLAN => ['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 3913c69..ead7880 100644 --- a/app/Services/Care/SpecialtyClinicalRecordService.php +++ b/app/Services/Care/SpecialtyClinicalRecordService.php @@ -471,6 +471,39 @@ class SpecialtyClinicalRecordService } } + + if ($moduleKey === 'oncology' && $recordType === 'onc_staging') { + $ecog = (string) ($payload['performance_status'] ?? ''); + if (in_array($ecog, ['3', '4'], true)) { + $alerts[] = [ + 'code' => 'onc.ecog_high', + 'severity' => 'warning', + 'message' => 'High ECOG performance status ('.$ecog.') — review treatment fitness.', + ]; + } + } + + if ($moduleKey === 'renal' && $recordType === 'renal_exam') { + $fluid = strtolower((string) ($payload['fluid_status'] ?? '')); + if (str_contains($fluid, 'overload')) { + $alerts[] = [ + 'code' => 'ren.fluid_overload', + 'severity' => 'warning', + 'message' => 'Fluid overload recorded on renal exam.', + ]; + } + } + + if ($moduleKey === 'surgery' && $recordType === 'surg_plan') { + if (empty($payload['consent_obtained']) && ! empty($payload['procedure_planned'])) { + $alerts[] = [ + 'code' => 'sur.consent_missing', + 'severity' => 'critical', + 'message' => 'Procedure planned without documented consent.', + ]; + } + } + if ($moduleKey === 'psychiatry' && $recordType === 'risk_assessment') { $level = strtolower((string) ($payload['overall_risk'] ?? '')); if (str_contains($level, 'imminent')) { diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php index b77c81a..a4620f0 100644 --- a/app/Services/Care/SpecialtyShellService.php +++ b/app/Services/Care/SpecialtyShellService.php @@ -20,6 +20,9 @@ use App\Services\Care\Psychiatry\PsychiatryWorkflowService; use App\Services\Care\Pediatrics\PediatricsWorkflowService; use App\Services\Care\Orthopedics\OrthopedicsWorkflowService; 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 Illuminate\Support\Collection; /** @@ -72,6 +75,9 @@ class SpecialtyShellService 'pediatrics' => 'care.specialty.pediatrics.stage', 'orthopedics' => 'care.specialty.orthopedics.stage', 'ent' => 'care.specialty.ent.stage', + 'oncology' => 'care.specialty.oncology.stage', + 'renal' => 'care.specialty.renal.stage', + 'surgery' => 'care.specialty.surgery.stage', default => null, }; } @@ -95,6 +101,9 @@ class SpecialtyShellService 'pediatrics' => app(PediatricsWorkflowService::class)->stageFlow(), 'orthopedics' => app(OrthopedicsWorkflowService::class)->stageFlow(), 'ent' => app(EntWorkflowService::class)->stageFlow(), + 'oncology' => app(OncologyWorkflowService::class)->stageFlow(), + 'renal' => app(RenalWorkflowService::class)->stageFlow(), + 'surgery' => app(SurgeryWorkflowService::class)->stageFlow(), 'dentistry' => [ 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], @@ -394,7 +403,7 @@ class SpecialtyShellService ) { $code = (string) ($stage['code'] ?? ''); - if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent'], true) && $visitIds->isNotEmpty()) { + if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery'], true) && $visitIds->isNotEmpty()) { $count = Visit::owned($ownerRef) ->where('organization_id', $organization->id) ->whereIn('id', $visitIds) @@ -433,6 +442,9 @@ class SpecialtyShellService 'pediatrics' => $clinical->countOpenByType($organization, 'pediatrics', 'pediatric_exam', $branchScope), 'orthopedics' => $clinical->countOpenByType($organization, 'orthopedics', 'ortho_exam', $branchScope), 'ent' => $clinical->countOpenByType($organization, 'ent', 'ent_exam', $branchScope), + 'oncology' => $clinical->countOpenByType($organization, 'oncology', 'onc_staging', $branchScope), + 'renal' => $clinical->countOpenByType($organization, 'renal', 'renal_exam', $branchScope), + 'surgery' => $clinical->countOpenByType($organization, 'surgery', 'surg_exam', $branchScope), 'dentistry' => \App\Models\DentalPlanItem::query() ->whereHas('plan', function ($q) use ($organization, $ownerRef) { $q->owned($ownerRef) diff --git a/app/Services/Care/Surgery/SurgeryAnalyticsService.php b/app/Services/Care/Surgery/SurgeryAnalyticsService.php new file mode 100644 index 0000000..d377dec --- /dev/null +++ b/app/Services/Care/Surgery/SurgeryAnalyticsService.php @@ -0,0 +1,161 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'surgery', $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'); + + $preopOpen = Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->whereIn('specialty_stage', ['history', 'exam', 'investigation', 'plan']) + ->count(); + + $planRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'surgery') + ->where('record_type', 'surg_plan') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $diagnosisBreakdown = $planRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['diagnosis'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $diagnosis) => [ + 'diagnosis' => $diagnosis, + 'total' => $rows->count(), + ]) + ->values() + ->sortByDesc('total') + ->values(); + + $procedureRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'surgery') + ->where('record_type', 'surg_procedure') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $procedureBreakdown = $procedureRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['procedure'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $procedure) => [ + 'procedure' => $procedure, + '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, 'surgery')) + ->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, + 'preop_open' => $preopOpen, + 'diagnosis_breakdown' => $diagnosisBreakdown, + 'procedure_breakdown' => $procedureBreakdown, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/Surgery/SurgeryWorkflowService.php b/app/Services/Care/Surgery/SurgeryWorkflowService.php new file mode 100644 index 0000000..376acbd --- /dev/null +++ b/app/Services/Care/Surgery/SurgeryWorkflowService.php @@ -0,0 +1,119 @@ + $payload + */ + public function stageFromHistory(array $payload): string + { + $history = trim((string) ($payload['history'] ?? '')); + if ($history !== '') { + return self::STAGE_HISTORY; + } + + return self::STAGE_CHECK_IN; + } + + /** + * @param array $payload + */ + public function stageFromExam(array $payload): string + { + if (! empty($payload['chief_complaint']) || ! empty($payload['exam_findings'])) { + return self::STAGE_EXAM; + } + + return self::STAGE_HISTORY; + } + + /** + * @param array $payload + */ + public function stageFromInvestigation(array $payload): string + { + $findings = trim((string) ($payload['findings'] ?? '')); + if ($findings !== '') { + return self::STAGE_INVESTIGATION; + } + + return self::STAGE_EXAM; + } + + /** + * @param array $payload + */ + public function stageFromPlan(array $payload): string + { + if (! empty($payload['procedure_planned'])) { + return self::STAGE_PROCEDURE; + } + + $plan = trim((string) ($payload['plan'] ?? '')); + if ($plan !== '') { + return self::STAGE_PLAN; + } + + return self::STAGE_INVESTIGATION; + } + + /** + * @param array $payload + */ + public function stageFromProcedure(array $payload): string + { + if (! empty($payload['procedure']) || ! empty($payload['outcome'])) { + return self::STAGE_PROCEDURE; + } + + return self::STAGE_PLAN; + } + + /** + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return str_contains($outcome, 'completed') || str_contains($outcome, 'discharged'); + } + + /** + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_CHECK_IN => ['next' => self::STAGE_HISTORY, 'label' => 'Start pre-op history'], + self::STAGE_HISTORY => ['next' => self::STAGE_EXAM, 'label' => 'Move to exam'], + self::STAGE_EXAM => ['next' => self::STAGE_INVESTIGATION, 'label' => 'Move to investigations'], + self::STAGE_INVESTIGATION => ['next' => self::STAGE_PLAN, 'label' => 'Move to consent / plan'], + self::STAGE_PLAN => ['next' => self::STAGE_PROCEDURE, 'label' => 'Move to procedure'], + self::STAGE_PROCEDURE => ['next' => self::STAGE_POSTOP, 'label' => 'Move to post-op'], + self::STAGE_POSTOP => ['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 69b1af1..c744e3f 100644 --- a/config/care_specialty_clinical.php +++ b/config/care_specialty_clinical.php @@ -87,16 +87,25 @@ return [ 'procedure' => 'ent_procedure', ], 'oncology' => [ - 'review' => 'oncology_review', - 'clinical_notes' => 'clinical_note', + 'history' => 'onc_history', + 'staging' => 'onc_staging', + 'investigations' => 'onc_investigation', + 'plan' => 'onc_plan', + 'treatment' => 'onc_treatment', ], 'renal' => [ - 'session' => 'renal_session', - 'clinical_notes' => 'clinical_note', + 'history' => 'renal_history', + 'exam' => 'renal_exam', + 'dialysis' => 'renal_session', + 'plan' => 'renal_plan', ], 'surgery' => [ - 'preop' => 'preop', - 'clinical_notes' => 'clinical_note', + 'history' => 'surg_history', + 'exam' => 'surg_exam', + 'investigations' => 'surg_investigation', + 'plan' => 'surg_plan', + 'procedure' => 'surg_procedure', + 'postop' => 'surg_postop', ], 'vaccination' => [ 'screening' => 'vax_screening', @@ -620,58 +629,133 @@ return [ ], ], 'oncology' => [ - 'oncology_review' => [ + 'onc_history' => [ + ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4], + ['name' => 'cancer_history', 'label' => 'Cancer / treatment history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'], + ['name' => 'comorbidities', 'label' => 'Comorbidities', 'type' => 'textarea', 'rows' => 2], + ['name' => 'social', 'label' => 'Social / support context', 'type' => 'textarea', 'rows' => 2], + ], + 'onc_staging' => [ ['name' => 'diagnosis', 'label' => 'Oncology diagnosis', 'type' => 'text', 'required' => true], - ['name' => 'stage', 'label' => 'Stage', 'type' => 'text'], + ['name' => 'stage', 'label' => 'Stage / TNM', 'type' => 'text'], + ['name' => 'histology', 'label' => 'Histology', 'type' => 'text'], ['name' => 'performance_status', 'label' => 'Performance status (ECOG)', 'type' => 'select', 'options' => ['0', '1', '2', '3', '4']], - ['name' => 'current_regimen', 'label' => 'Current regimen', 'type' => 'textarea', 'rows' => 2], + ['name' => 'exam_findings', 'label' => 'Exam findings', 'type' => 'textarea', 'rows' => 3], + ['name' => 'sites', 'label' => 'Disease sites', 'type' => 'textarea', 'rows' => 2], + ], + 'onc_investigation' => [ + ['name' => 'investigation', 'label' => 'Investigation', 'type' => 'text', 'required' => true], + ['name' => 'findings', 'label' => 'Findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'impression', 'label' => 'Impression', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], + ], + 'onc_plan' => [ + ['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true], + ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'intent', 'label' => 'Treatment intent', 'type' => 'select', 'options' => ['Curative', 'Adjuvant', 'Neoadjuvant', 'Palliative', 'Supportive']], + ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'treatment_planned', 'label' => 'Treatment / chemo planned', 'type' => 'boolean'], + ['name' => 'follow_up', 'label' => 'Follow-up', 'type' => 'text'], + ['name' => 'advice', 'label' => 'Patient advice', 'type' => 'textarea', 'rows' => 2], + ], + 'onc_treatment' => [ + ['name' => 'treatment', 'label' => 'Treatment / chemo note', 'type' => 'text', 'required' => true], + ['name' => 'regimen', 'label' => 'Regimen', 'type' => 'text'], ['name' => 'cycle', 'label' => 'Cycle / day', 'type' => 'text'], ['name' => 'toxicity', 'label' => 'Toxicity / side effects', 'type' => 'textarea', 'rows' => 2], - ['name' => 'response', 'label' => 'Treatment response', 'type' => 'textarea', 'rows' => 2], - ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'required' => true, 'rows' => 2], - ], - '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], + ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed uneventfully', 'Completed with complications', 'Deferred', 'Aborted']], + ['name' => 'post_treatment_plan', 'label' => 'Post-treatment plan', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], ], ], 'renal' => [ + 'renal_history' => [ + ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4], + ['name' => 'ckd_stage', 'label' => 'CKD stage / dialysis vintage', 'type' => 'text'], + ['name' => 'access_history', 'label' => 'Access history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'], + ['name' => 'comorbidities', 'label' => 'Comorbidities', 'type' => 'textarea', 'rows' => 2], + ], + 'renal_exam' => [ + ['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true], + ['name' => 'bp', 'label' => 'Blood pressure', 'type' => 'text'], + ['name' => 'weight_kg', 'label' => 'Weight (kg)', 'type' => 'number'], + ['name' => 'fluid_status', 'label' => 'Fluid status', 'type' => 'select', 'options' => ['Euvolemic', 'Overload', 'Dehydrated', 'Not assessed']], + ['name' => 'access', 'label' => 'Access assessment', 'type' => 'text'], + ['name' => 'exam_findings', 'label' => 'Exam findings', 'type' => 'textarea', 'rows' => 3], + ['name' => 'uremic_symptoms', 'label' => 'Uremic symptoms', 'type' => 'textarea', 'rows' => 2], + ], 'renal_session' => [ - ['name' => 'session_type', 'label' => 'Session type', 'type' => 'select', 'required' => true, 'options' => ['Hemodialysis', 'Peritoneal dialysis', 'Clinic review', 'Other']], + ['name' => 'session_type', 'label' => 'Session / labs type', 'type' => 'select', 'required' => true, 'options' => ['Hemodialysis', 'Peritoneal dialysis', 'Clinic labs review', 'Other']], ['name' => 'pre_weight_kg', 'label' => 'Pre-weight (kg)', 'type' => 'number'], ['name' => 'post_weight_kg', 'label' => 'Post-weight (kg)', 'type' => 'number'], ['name' => 'uf_goal_l', 'label' => 'UF goal (L)', 'type' => 'number'], ['name' => 'access', 'label' => 'Access', 'type' => 'text'], ['name' => 'bp_pre', 'label' => 'BP pre', 'type' => 'text'], ['name' => 'bp_post', 'label' => 'BP post', 'type' => 'text'], + ['name' => 'labs_summary', 'label' => 'Labs summary', 'type' => 'textarea', 'rows' => 2], ['name' => 'complications', 'label' => 'Complications', 'type' => 'textarea', 'rows' => 2], - ['name' => 'notes', 'label' => 'Session notes', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'notes', 'label' => 'Session / labs notes', 'type' => 'textarea', 'required' => true, 'rows' => 3], ], - 'clinical_note' => [ - ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4], - ['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3], - ['name' => 'working_diagnosis', 'label' => 'Working diagnosis', 'type' => 'text'], - ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 3], + 'renal_plan' => [ + ['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true], + ['name' => 'plan', 'label' => 'Care plan', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'dialysis_prescription', 'label' => 'Dialysis prescription notes', 'type' => 'textarea', 'rows' => 2], + ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'follow_up', 'label' => 'Follow-up', 'type' => 'text'], + ['name' => 'outcome', 'label' => 'Visit outcome', 'type' => 'select', 'required' => true, 'options' => ['In progress', 'Completed — continue plan', 'Completed — discharged', 'Referred', 'Admitted']], + ['name' => 'advice', 'label' => 'Patient advice', 'type' => 'textarea', 'rows' => 2], ], ], 'surgery' => [ - 'preop' => [ - ['name' => 'proposed_procedure', 'label' => 'Proposed procedure', 'type' => 'text', 'required' => true], - ['name' => 'indication', 'label' => 'Indication', 'type' => 'textarea', 'required' => true, 'rows' => 2], - ['name' => 'asa_class', 'label' => 'ASA class', 'type' => 'select', 'options' => ['I', 'II', 'III', 'IV', 'V', 'E']], - ['name' => 'allergies_reviewed', 'label' => 'Allergies reviewed', 'type' => 'boolean'], - ['name' => 'consent_obtained', 'label' => 'Consent obtained', 'type' => 'boolean'], - ['name' => 'fitness', 'label' => 'Fitness for anaesthesia', 'type' => 'textarea', 'rows' => 2], - ['name' => 'investigations', 'label' => 'Relevant investigations', 'type' => 'textarea', 'rows' => 2], - ['name' => 'plan', 'label' => 'Peri-op plan', 'type' => 'textarea', 'required' => true, 'rows' => 2], - ], - 'clinical_note' => [ + 'surg_history' => [ ['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], + ['name' => 'past_surgical', 'label' => 'Past surgical history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'], + ['name' => 'comorbidities', 'label' => 'Comorbidities', 'type' => 'textarea', 'rows' => 2], + ['name' => 'anaesthesia_history', 'label' => 'Anaesthesia history', 'type' => 'textarea', 'rows' => 2], + ], + 'surg_exam' => [ + ['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true], + ['name' => 'exam_findings', 'label' => 'Examination findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'asa_class', 'label' => 'ASA class', 'type' => 'select', 'options' => ['I', 'II', 'III', 'IV', 'V', 'E']], + ['name' => 'fitness', 'label' => 'Fitness notes', 'type' => 'textarea', 'rows' => 2], + ['name' => 'site_marking', 'label' => 'Site marking reviewed', 'type' => 'boolean'], + ], + 'surg_investigation' => [ + ['name' => 'investigation', 'label' => 'Investigation', 'type' => 'text', 'required' => true], + ['name' => 'findings', 'label' => 'Findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'impression', 'label' => 'Impression', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], + ], + 'surg_plan' => [ + ['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true], + ['name' => 'proposed_procedure', 'label' => 'Proposed procedure', 'type' => 'text'], + ['name' => 'plan', 'label' => 'Consent / peri-op plan', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'consent_obtained', 'label' => 'Consent obtained', 'type' => 'boolean'], + ['name' => 'procedure_planned', 'label' => 'Procedure planned today', 'type' => 'boolean'], + ['name' => 'follow_up', 'label' => 'Follow-up', 'type' => 'text'], + ['name' => 'advice', 'label' => 'Patient advice', 'type' => 'textarea', 'rows' => 2], + ], + 'surg_procedure' => [ + ['name' => 'procedure', 'label' => 'Procedure', 'type' => 'text', 'required' => true], + ['name' => 'indication', 'label' => 'Indication', 'type' => 'textarea', 'rows' => 2], + ['name' => 'technique', 'label' => 'Technique / findings', 'type' => 'textarea', 'rows' => 2], + ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed uneventfully', 'Completed with complications', 'Deferred', 'Aborted']], + ['name' => 'complications', 'label' => 'Complications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], + ], + 'surg_postop' => [ + ['name' => 'review_type', 'label' => 'Review type', 'type' => 'select', 'required' => true, 'options' => ['Immediate post-op', 'Clinic wound check', 'Follow-up review', 'Discharge review']], + ['name' => 'progress', 'label' => 'Progress / wound status', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'pain_score', 'label' => 'Pain score (0–10)', 'type' => 'number'], + ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['In progress', 'Completed — continue plan', 'Completed — discharged', 'Referred', 'Admitted']], + ['name' => 'next_steps', 'label' => 'Next steps', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], ], ], 'vaccination' => [ diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index 80ada02..43b4558 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -513,62 +513,114 @@ return [ ], 'oncology' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], - ['code' => 'review', 'label' => 'Oncology review', 'queue_point' => 'chair'], - ['code' => 'treatment', 'label' => 'Treatment', 'queue_point' => 'chair'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'history', 'label' => 'History', 'queue_point' => 'waiting'], + ['code' => 'staging', 'label' => 'Staging / exam', 'queue_point' => 'chair'], + ['code' => 'investigation', 'label' => 'Investigations', 'queue_point' => 'chair'], + ['code' => 'plan', 'label' => 'Diagnosis & plan', 'queue_point' => 'chair'], + ['code' => 'treatment', 'label' => 'Treatment / chemo', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ ['code' => 'onc.consult', 'label' => 'Oncology consultation', 'amount_minor' => 10000, 'type' => 'consultation'], + ['code' => 'onc.staging', 'label' => 'Staging assessment', 'amount_minor' => 8000, 'type' => 'consultation'], ['code' => 'onc.chemo', 'label' => 'Chemo day-care session', 'amount_minor' => 50000, 'type' => 'procedure'], + ['code' => 'onc.supportive', 'label' => 'Supportive care visit', 'amount_minor' => 6000, 'type' => 'consultation'], ], 'workspace_tabs' => [ 'overview' => 'Overview', - 'review' => 'Oncology review', - 'clinical_notes' => 'Clinical notes', + 'history' => 'History', + 'staging' => 'Staging / exam', + 'investigations' => 'Investigations', + 'plan' => 'Diagnosis & plan', + 'treatment' => 'Treatment', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'history' => 'history', + 'staging' => 'staging', + 'investigation' => 'investigations', + 'plan' => 'plan', + 'treatment' => 'treatment', + 'completed' => 'overview', + ], ], 'renal' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], - ['code' => 'dialysis', 'label' => 'Dialysis / clinic', 'queue_point' => 'chair'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'history', 'label' => 'History', 'queue_point' => 'waiting'], + ['code' => 'exam', 'label' => 'Exam', 'queue_point' => 'chair'], + ['code' => 'dialysis', 'label' => 'Dialysis / labs', 'queue_point' => 'chair'], + ['code' => 'plan', 'label' => 'Care plan', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ ['code' => 'ren.consult', 'label' => 'Nephrology consultation', 'amount_minor' => 8000, 'type' => 'consultation'], ['code' => 'ren.dialysis', 'label' => 'Dialysis session', 'amount_minor' => 35000, 'type' => 'procedure'], + ['code' => 'ren.labs', 'label' => 'Renal labs panel', 'amount_minor' => 6000, 'type' => 'lab'], + ['code' => 'ren.review', 'label' => 'Clinic review', 'amount_minor' => 5000, 'type' => 'consultation'], ], 'workspace_tabs' => [ 'overview' => 'Overview', - 'session' => 'Renal session', - 'clinical_notes' => 'Clinical notes', + 'history' => 'History', + 'exam' => 'Exam', + 'dialysis' => 'Dialysis / labs', + 'plan' => 'Care plan', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'history' => 'history', + 'exam' => 'exam', + 'dialysis' => 'dialysis', + 'plan' => 'plan', + 'completed' => 'overview', + ], ], 'surgery' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], - ['code' => 'preop', 'label' => 'Pre-op assessment', 'queue_point' => 'chair'], - ['code' => 'postop', 'label' => 'Post-op review', 'queue_point' => 'chair'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'history', 'label' => 'Pre-op history', 'queue_point' => 'waiting'], + ['code' => 'exam', 'label' => 'Exam', 'queue_point' => 'chair'], + ['code' => 'investigation', 'label' => 'Investigations', 'queue_point' => 'chair'], + ['code' => 'plan', 'label' => 'Consent / plan', 'queue_point' => 'chair'], + ['code' => 'procedure', 'label' => 'Procedure', 'queue_point' => 'chair'], + ['code' => 'postop', 'label' => 'Post-op', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ ['code' => 'sur.consult', 'label' => 'Surgical consultation', 'amount_minor' => 8000, 'type' => 'consultation'], ['code' => 'sur.preop', 'label' => 'Pre-op assessment', 'amount_minor' => 5000, 'type' => 'consultation'], + ['code' => 'sur.procedure', 'label' => 'Minor procedure / clinic', 'amount_minor' => 25000, 'type' => 'procedure'], + ['code' => 'sur.postop', 'label' => 'Post-op review', 'amount_minor' => 4500, 'type' => 'consultation'], ], 'workspace_tabs' => [ 'overview' => 'Overview', - 'preop' => 'Pre-op', - 'clinical_notes' => 'Clinical notes', + 'history' => 'History', + 'exam' => 'Exam', + 'investigations' => 'Investigations', + 'plan' => 'Consent / plan', + 'procedure' => 'Procedure', + 'postop' => 'Post-op', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'history' => 'history', + 'exam' => 'exam', + 'investigation' => 'investigations', + 'plan' => 'plan', + 'procedure' => 'procedure', + 'postop' => 'postop', + 'completed' => 'overview', + ], ], 'vaccination' => [ 'stages' => [ diff --git a/resources/views/care/specialty/oncology/print.blade.php b/resources/views/care/specialty/oncology/print.blade.php new file mode 100644 index 0000000..7fccffb --- /dev/null +++ b/resources/views/care/specialty/oncology/print.blade.php @@ -0,0 +1,33 @@ + + + + + Oncology summary · {{ $patient->fullName() }} + + + +

Back

+

{{ $patient->fullName() }}

+

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

+

History

+ @if ($history)
HPI
{{ $history->payload['history'] ?? '—' }}
Cancer history
{{ $history->payload['cancer_history'] ?? '—' }}
@else

No history recorded.

@endif +

Staging / exam

+ @if ($staging)
Diagnosis
{{ $staging->payload['diagnosis'] ?? '—' }}
Stage
{{ $staging->payload['stage'] ?? '—' }}
ECOG
{{ $staging->payload['performance_status'] ?? '—' }}
@else

No staging recorded.

@endif +

Investigations

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

No investigation recorded.

@endif +

Plan

+ @if ($plan)
Diagnosis
{{ $plan->payload['diagnosis'] ?? '—' }}
Plan
{{ $plan->payload['plan'] ?? '—' }}
@else

No plan recorded.

@endif +

Treatment

+ @if ($treatment)
Treatment
{{ $treatment->payload['treatment'] ?? '—' }}
Outcome
{{ $treatment->payload['outcome'] ?? '—' }}
@else

No treatment recorded.

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

Oncology reports

+

Arrivals, open chemo notes, diagnoses, regimens, length of stay, and oncology revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

Chemo notes open

+

{{ number_format($report['chemo_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 staging records in range.
  • + @endforelse +
+
+
+

Regimens / treatments

+
    + @forelse ($report['regimen_breakdown'] as $row) +
  • + {{ $row['regimen'] }} + {{ $row['total'] }} +
  • + @empty +
  • No treatments in range.
  • + @endforelse +
+
+
+ +
+

Oncology service revenue

+
    + @forelse ($report['revenue_by_service'] as $row) +
  • + {{ $row->description }} ×{{ $row->total }} + {{ $currency }} {{ number_format($row->amount_minor / 100, 2) }} +
  • + @empty +
  • No billed oncology services in range.
  • + @endforelse +
+
+
+
diff --git a/resources/views/care/specialty/oncology/workspace-overview.blade.php b/resources/views/care/specialty/oncology/workspace-overview.blade.php new file mode 100644 index 0000000..b012ff4 --- /dev/null +++ b/resources/views/care/specialty/oncology/workspace-overview.blade.php @@ -0,0 +1,60 @@ +@php + $hist = $oncologyHistory?->payload ?? []; + $staging = $oncologyStaging?->payload ?? []; + $plan = $oncologyPlan?->payload ?? []; +@endphp + +
+
+
+

Oncology overview

+

History, staging, investigations, plan, and treatment for this episode.

+
+ +
+ +
+
+

Diagnosis

+

{{ $staging['diagnosis'] ?? ($plan['diagnosis'] ?? '—') }}

+

Stage {{ $staging['stage'] ?? '—' }}

+
+
+

ECOG

+

{{ $staging['performance_status'] ?? '—' }}

+

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

+
+
+

Plan

+

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

+

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

+
+
+ +
+
+
History
+
{{ $hist['history'] ?? '—' }}
+
+
+
Queue
+
+ {{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }} +
+
+
+ + @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

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

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

Treatment / chemo

+

Completed treatments can close the oncology 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 treatment recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/renal/print.blade.php b/resources/views/care/specialty/renal/print.blade.php new file mode 100644 index 0000000..67f2b56 --- /dev/null +++ b/resources/views/care/specialty/renal/print.blade.php @@ -0,0 +1,12 @@ + +Renal summary · {{ $patient->fullName() }} + + +

Back

+

{{ $patient->fullName() }}

+

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

+

History

@if($history)
HPI
{{ $history->payload['history'] ?? '—' }}
CKD
{{ $history->payload['ckd_stage'] ?? '—' }}
@else

No history.

@endif +

Exam

@if($exam)
Complaint
{{ $exam->payload['chief_complaint'] ?? '—' }}
Fluid
{{ $exam->payload['fluid_status'] ?? '—' }}
@else

No exam.

@endif +

Dialysis / labs

@if($dialysis)
Type
{{ $dialysis->payload['session_type'] ?? '—' }}
Notes
{{ $dialysis->payload['notes'] ?? '—' }}
@else

No session.

@endif +

Plan

@if($plan)
Diagnosis
{{ $plan->payload['diagnosis'] ?? '—' }}
Plan
{{ $plan->payload['plan'] ?? '—' }}
Outcome
{{ $plan->payload['outcome'] ?? '—' }}
@else

No plan.

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

Renal Care reports

+

Arrivals, dialysis sessions, diagnoses, length of stay, and renal revenue.

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

Arrivals today

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

+

Completed today

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

+

Dialysis stage open

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

+

Avg LOS

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

+
+
+
+

Session types

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

Diagnoses

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

Renal service revenue

+
    + @forelse ($report['revenue_by_service'] as $row) +
  • {{ $row->description }} ×{{ $row->total }}{{ $currency }} {{ number_format($row->amount_minor / 100, 2) }}
  • + @empty
  • No billed renal services in range.
  • @endforelse +
+
+
+
diff --git a/resources/views/care/specialty/renal/workspace-overview.blade.php b/resources/views/care/specialty/renal/workspace-overview.blade.php new file mode 100644 index 0000000..256ec01 --- /dev/null +++ b/resources/views/care/specialty/renal/workspace-overview.blade.php @@ -0,0 +1,46 @@ +@php + $hist = $renalHistory?->payload ?? []; + $exam = $renalExam?->payload ?? []; + $plan = $renalPlan?->payload ?? []; +@endphp +
+
+
+

Renal Care overview

+

History, exam, dialysis/labs, and care plan for this episode.

+
+ +
+
+
+

Complaint

+

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

+

Fluid {{ $exam['fluid_status'] ?? '—' }}

+
+
+

CKD / access

+

{{ $hist['ckd_stage'] ?? '—' }}

+

{{ $exam['access'] ?? '—' }}

+
+
+

Diagnosis / plan

+

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

+

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

+
+
+
+
History
{{ $hist['history'] ?? '—' }}
+
Plan
{{ $plan['plan'] ?? '—' }}
+
+ @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

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

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

Care plan

+

Completed / discharged outcomes can close the renal 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 care plan recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index 7cb801c..0a3fca3 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -79,6 +79,12 @@ @include('care.specialty.orthopedics.workspace-'.$activeTab) @elseif ($moduleKey === 'ent' && in_array($activeTab, ['overview', 'procedure'], true)) @include('care.specialty.ent.workspace-'.$activeTab) + @elseif ($moduleKey === 'oncology' && in_array($activeTab, ['overview', 'treatment'], true)) + @include('care.specialty.oncology.workspace-'.$activeTab) + @elseif ($moduleKey === 'renal' && in_array($activeTab, ['overview', 'plan'], true)) + @include('care.specialty.renal.workspace-'.$activeTab) + @elseif ($moduleKey === 'surgery' && in_array($activeTab, ['overview', 'postop'], true)) + @include('care.specialty.surgery.workspace-'.$activeTab) @elseif ($activeTab === 'billing')

diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index bf96d7e..1a191a1 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -61,6 +61,15 @@ @if ($moduleKey === 'ent') Reports @endif + @if ($moduleKey === 'oncology') + Reports + @endif + @if ($moduleKey === 'renal') + Reports + @endif + @if ($moduleKey === 'surgery') + Reports + @endif History @if ($canManageClinical ?? $canManageSpecialty ?? true) Billing diff --git a/resources/views/care/specialty/surgery/print.blade.php b/resources/views/care/specialty/surgery/print.blade.php new file mode 100644 index 0000000..8721e82 --- /dev/null +++ b/resources/views/care/specialty/surgery/print.blade.php @@ -0,0 +1,14 @@ + +Surgery summary · {{ $patient->fullName() }} + + +

Back

+

{{ $patient->fullName() }}

+

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

+

History

@if($history)
HPI
{{ $history->payload['history'] ?? '—' }}
@else

No history.

@endif +

Exam

@if($exam)
Complaint
{{ $exam->payload['chief_complaint'] ?? '—' }}
ASA
{{ $exam->payload['asa_class'] ?? '—' }}
@else

No exam.

@endif +

Investigations

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

No investigation.

@endif +

Consent / plan

@if($plan)
Diagnosis
{{ $plan->payload['diagnosis'] ?? '—' }}
Procedure
{{ $plan->payload['proposed_procedure'] ?? '—' }}
Consent
{{ !empty($plan->payload['consent_obtained']) ? 'Yes' : 'No' }}
@else

No plan.

@endif +

Procedure

@if($procedure)
Procedure
{{ $procedure->payload['procedure'] ?? '—' }}
Outcome
{{ $procedure->payload['outcome'] ?? '—' }}
@else

No procedure.

@endif +

Post-op

@if($postop)
Progress
{{ $postop->payload['progress'] ?? '—' }}
Outcome
{{ $postop->payload['outcome'] ?? '—' }}
@else

No post-op.

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

Surgery reports

+

Arrivals, pre-op pipeline, diagnoses, procedures, length of stay, and surgery revenue.

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

Arrivals today

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

+

Completed today

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

+

Pre-op pipeline

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

+

Avg LOS

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

+
+
+

Diagnoses

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

Procedures

+
    @forelse ($report['procedure_breakdown'] as $row)
  • {{ $row['procedure'] }}{{ $row['total'] }}
  • @empty
  • No procedures in range.
  • @endforelse
+
+
+

Surgery service revenue

+
    @forelse ($report['revenue_by_service'] as $row)
  • {{ $row->description }} ×{{ $row->total }}{{ $currency }} {{ number_format($row->amount_minor / 100, 2) }}
  • @empty
  • No billed surgery services in range.
  • @endforelse
+
+
+
diff --git a/resources/views/care/specialty/surgery/workspace-overview.blade.php b/resources/views/care/specialty/surgery/workspace-overview.blade.php new file mode 100644 index 0000000..8b3674b --- /dev/null +++ b/resources/views/care/specialty/surgery/workspace-overview.blade.php @@ -0,0 +1,46 @@ +@php + $hist = $surgeryHistory?->payload ?? []; + $exam = $surgeryExam?->payload ?? []; + $plan = $surgeryPlan?->payload ?? []; +@endphp +
+
+
+

Surgery overview

+

Pre-op history, exam, investigations, consent/plan, procedure, and post-op.

+
+ +
+
+
+

Complaint

+

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

+

ASA {{ $exam['asa_class'] ?? '—' }}

+
+
+

Proposed procedure

+

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

+

{{ ! empty($plan['consent_obtained']) ? 'Consent obtained' : 'Consent pending' }}

+
+
+

Diagnosis / plan

+

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

+

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

+
+
+
+
History
{{ $hist['history'] ?? '—' }}
+
Plan
{{ $plan['plan'] ?? '—' }}
+
+ @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

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

+ @endforeach +
+ @endif +
diff --git a/resources/views/care/specialty/surgery/workspace-postop.blade.php b/resources/views/care/specialty/surgery/workspace-postop.blade.php new file mode 100644 index 0000000..94d79b4 --- /dev/null +++ b/resources/views/care/specialty/surgery/workspace-postop.blade.php @@ -0,0 +1,67 @@ +@php + $record = $surgeryPostop; + $payload = $record?->payload ?? []; + $canManage = $canManageClinical ?? $canConsult ?? false; +@endphp +
+
+
+

Post-op

+

Completed / discharged outcomes can close the surgery 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 post-op note recorded yet.

+ @endif +
diff --git a/routes/web.php b/routes/web.php index 14901b5..67488b0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -50,6 +50,9 @@ use App\Http\Controllers\Care\PsychiatryWorkspaceController; use App\Http\Controllers\Care\PediatricsWorkspaceController; use App\Http\Controllers\Care\OrthopedicsWorkspaceController; 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\SpecialtyModuleController; use App\Http\Controllers\Care\VisitWorkflowController; use App\Http\Controllers\NotificationController; @@ -141,6 +144,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/specialty/pediatrics/reports', [PediatricsWorkspaceController::class, 'reports'])->name('care.specialty.pediatrics.reports'); Route::get('/specialty/orthopedics/reports', [OrthopedicsWorkspaceController::class, 'reports'])->name('care.specialty.orthopedics.reports'); Route::get('/specialty/ent/reports', [EntWorkspaceController::class, 'reports'])->name('care.specialty.ent.reports'); + 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/{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'); @@ -201,6 +207,15 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/specialty/ent/workspace/{visit}/stage', [EntWorkspaceController::class, 'setStage'])->name('care.specialty.ent.stage'); Route::post('/specialty/ent/workspace/{visit}/procedure', [EntWorkspaceController::class, 'saveProcedure'])->name('care.specialty.ent.procedure'); Route::get('/specialty/ent/workspace/{visit}/print', [EntWorkspaceController::class, 'printSummary'])->name('care.specialty.ent.print'); + Route::post('/specialty/oncology/workspace/{visit}/stage', [OncologyWorkspaceController::class, 'setStage'])->name('care.specialty.oncology.stage'); + Route::post('/specialty/oncology/workspace/{visit}/treatment', [OncologyWorkspaceController::class, 'saveTreatment'])->name('care.specialty.oncology.treatment'); + Route::get('/specialty/oncology/workspace/{visit}/print', [OncologyWorkspaceController::class, 'printSummary'])->name('care.specialty.oncology.print'); + Route::post('/specialty/renal/workspace/{visit}/stage', [RenalWorkspaceController::class, 'setStage'])->name('care.specialty.renal.stage'); + Route::post('/specialty/renal/workspace/{visit}/plan', [RenalWorkspaceController::class, 'savePlan'])->name('care.specialty.renal.plan'); + Route::get('/specialty/renal/workspace/{visit}/print', [RenalWorkspaceController::class, 'printSummary'])->name('care.specialty.renal.print'); + 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::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/CareOncologySuiteTest.php b/tests/Feature/CareOncologySuiteTest.php new file mode 100644 index 0000000..d1bdbbf --- /dev/null +++ b/tests/Feature/CareOncologySuiteTest.php @@ -0,0 +1,233 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'onc-owner', + 'name' => 'Owner', + 'email' => 'onc-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Oncology Clinic', + 'slug' => 'onc-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, + 'oncology', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'oncology') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-ONC-SUITE', + 'first_name' => 'Ama', + 'last_name' => 'Cancer', + 'gender' => 'female', + 'date_of_birth' => '1988-09-02', + ]); + + $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' => 'oncology', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Oncology overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Start history') + ->assertSee('Oncology reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'oncology', + 'visit' => $this->visit, + 'tab' => 'staging', + ])) + ->assertOk() + ->assertSee('Oncology diagnosis'); + } + + public function test_clinical_save_sets_stage_and_alerts(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'oncology', + 'visit' => $this->visit, + ]), [ + 'tab' => 'staging', + 'payload' => [ + 'diagnosis' => 'Breast carcinoma', + 'stage' => 'IIA', + 'performance_status' => '3', + 'exam_findings' => 'Palpable mass', + ], + ]) + ->assertRedirect(); + + $this->assertSame('staging', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('onc.ecog_high', $codes); + + } + + public function test_stage_advance_and_terminal_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.oncology.stage', $this->visit), [ + 'stage' => 'treatment', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'oncology', + 'visit' => $this->visit, + 'tab' => 'treatment', + ])); + + $this->assertSame('treatment', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.oncology.treatment', $this->visit), [ + 'tab' => 'treatment', + 'payload' => [ + 'treatment' => 'AC cycle 1 day 1', + 'regimen' => 'AC', + 'cycle' => '1/4', + 'outcome' => 'Completed uneventfully', + 'post_treatment_plan' => 'Return cycle 2 in 21 days', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'oncology', + 'visit' => $this->visit, + 'tab' => 'treatment', + ])); + + $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' => 'onc_treatment', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.oncology.reports')) + ->assertOk() + ->assertSee('Oncology reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.oncology.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', 'oncology')) + ->assertOk() + ->assertDontSee('Oncology overview'); + } +} diff --git a/tests/Feature/CareRenalSuiteTest.php b/tests/Feature/CareRenalSuiteTest.php new file mode 100644 index 0000000..4c98239 --- /dev/null +++ b/tests/Feature/CareRenalSuiteTest.php @@ -0,0 +1,231 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'renal-owner', + 'name' => 'Owner', + 'email' => 'renal-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Renal Clinic', + 'slug' => 'renal-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, + 'renal', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'renal') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-REN-SUITE', + 'first_name' => 'Kojo', + 'last_name' => 'Kidney', + 'gender' => 'female', + 'date_of_birth' => '1988-09-02', + ]); + + $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' => 'renal', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Renal Care overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Start history') + ->assertSee('Renal reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'renal', + 'visit' => $this->visit, + 'tab' => 'exam', + ])) + ->assertOk() + ->assertSee('Chief complaint', false); + } + + public function test_clinical_save_sets_stage_and_alerts(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'renal', + 'visit' => $this->visit, + ]), [ + 'tab' => 'exam', + 'payload' => [ + 'chief_complaint' => 'Missed dialysis with oedema', + 'fluid_status' => 'Overload', + 'exam_findings' => 'Bilateral crackles', + ], + ]) + ->assertRedirect(); + + $this->assertSame('exam', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('ren.fluid_overload', $codes); + + } + + public function test_stage_advance_and_terminal_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.renal.stage', $this->visit), [ + 'stage' => 'plan', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'renal', + 'visit' => $this->visit, + 'tab' => 'plan', + ])); + + $this->assertSame('plan', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.renal.plan', $this->visit), [ + 'tab' => 'plan', + 'payload' => [ + 'diagnosis' => 'CKD 5D with fluid overload', + 'plan' => 'Urgent HD; adjust dry weight', + 'outcome' => 'Completed — discharged', + 'follow_up' => 'Next HD in 2 days', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'renal', + 'visit' => $this->visit, + 'tab' => 'plan', + ])); + + $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' => 'renal_plan', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.renal.reports')) + ->assertOk() + ->assertSee('Renal Care reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.renal.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', 'renal')) + ->assertOk() + ->assertDontSee('Renal Care overview'); + } +} diff --git a/tests/Feature/CareSurgerySuiteTest.php b/tests/Feature/CareSurgerySuiteTest.php new file mode 100644 index 0000000..0785c18 --- /dev/null +++ b/tests/Feature/CareSurgerySuiteTest.php @@ -0,0 +1,224 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'surg-owner', + 'name' => 'Owner', + 'email' => 'surg-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Surgery Clinic', + 'slug' => 'surg-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, + 'surgery', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'surgery') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-SUR-SUITE', + 'first_name' => 'Efua', + 'last_name' => 'Hernia', + 'gender' => 'female', + 'date_of_birth' => '1988-09-02', + ]); + + $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' => 'surgery', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Surgery overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Start pre-op history') + ->assertSee('Surgery reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'surgery', + 'visit' => $this->visit, + 'tab' => 'exam', + ])) + ->assertOk() + ->assertSee('Chief complaint', false); + } + + public function test_clinical_save_sets_stage_and_alerts(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'surgery', + 'visit' => $this->visit, + ]), [ + 'tab' => 'exam', + 'payload' => [ + 'chief_complaint' => 'Right inguinal hernia', + 'exam_findings' => 'Reducible hernia', + 'asa_class' => 'II', + ], + ]) + ->assertRedirect(); + + $this->assertSame('exam', $this->visit->fresh()->specialty_stage); + + } + + public function test_stage_advance_and_terminal_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.surgery.stage', $this->visit), [ + 'stage' => 'postop', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'surgery', + 'visit' => $this->visit, + 'tab' => 'postop', + ])); + + $this->assertSame('postop', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.surgery.postop', $this->visit), [ + 'tab' => 'postop', + 'payload' => [ + 'review_type' => 'Immediate post-op', + 'progress' => 'Stable; wound dry', + 'outcome' => 'Completed — discharged', + 'next_steps' => 'Wound check in 7 days', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'surgery', + 'visit' => $this->visit, + 'tab' => 'postop', + ])); + + $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' => 'surg_postop', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.surgery.reports')) + ->assertOk() + ->assertSee('Surgery reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.surgery.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', 'surgery')) + ->assertOk() + ->assertDontSee('Surgery overview'); + } +}