From 8c18ecc5c922f7bfd23c6e32cb9af4c28d63d58a Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sun, 19 Jul 2026 22:03:01 +0000 Subject: [PATCH] Add full Dermatology, Podiatry, and Fertility specialty clinical suites. Bring thin catalog modules to the same depth as Cardiology with stages, clinical tabs, reports/print, demo seed, and suite tests. Co-authored-by: Cursor --- .../Care/DermatologyWorkspaceController.php | 215 +++++++++++ .../Care/FertilityWorkspaceController.php | 215 +++++++++++ .../Care/PodiatryWorkspaceController.php | 215 +++++++++++ .../Care/SpecialtyModuleController.php | 180 +++++++++ app/Services/Care/DemoTenantSeeder.php | 341 ++++++++++++++++++ .../DermatologyAnalyticsService.php | 168 +++++++++ .../DermatologyWorkflowService.php | 104 ++++++ .../Fertility/FertilityAnalyticsService.php | 168 +++++++++ .../Fertility/FertilityWorkflowService.php | 104 ++++++ .../Podiatry/PodiatryAnalyticsService.php | 168 +++++++++ .../Care/Podiatry/PodiatryWorkflowService.php | 104 ++++++ .../Care/SpecialtyClinicalRecordService.php | 33 ++ app/Services/Care/SpecialtyShellService.php | 14 +- .../Care/SpecialtyVisitStageService.php | 24 ++ config/care_specialty_clinical.php | 142 ++++++-- config/care_specialty_shell.php | 72 +++- .../specialty/dermatology/print.blade.php | 89 +++++ .../specialty/dermatology/reports.blade.php | 88 +++++ .../dermatology/workspace-overview.blade.php | 71 ++++ .../dermatology/workspace-procedure.blade.php | 70 ++++ .../care/specialty/fertility/print.blade.php | 90 +++++ .../specialty/fertility/reports.blade.php | 88 +++++ .../fertility/workspace-cycle.blade.php | 70 ++++ .../fertility/workspace-overview.blade.php | 71 ++++ .../specialty/partials/actions-menu.blade.php | 6 + .../care/specialty/podiatry/print.blade.php | 90 +++++ .../care/specialty/podiatry/reports.blade.php | 88 +++++ .../podiatry/workspace-overview.blade.php | 71 ++++ .../podiatry/workspace-procedure.blade.php | 70 ++++ .../specialty/sections/workspace.blade.php | 6 + .../views/care/specialty/shell.blade.php | 9 + routes/web.php | 15 + tests/Feature/CareDermatologySuiteTest.php | 239 ++++++++++++ tests/Feature/CareFertilitySuiteTest.php | 239 ++++++++++++ tests/Feature/CarePodiatrySuiteTest.php | 239 ++++++++++++ 35 files changed, 3937 insertions(+), 39 deletions(-) create mode 100644 app/Http/Controllers/Care/DermatologyWorkspaceController.php create mode 100644 app/Http/Controllers/Care/FertilityWorkspaceController.php create mode 100644 app/Http/Controllers/Care/PodiatryWorkspaceController.php create mode 100644 app/Services/Care/Dermatology/DermatologyAnalyticsService.php create mode 100644 app/Services/Care/Dermatology/DermatologyWorkflowService.php create mode 100644 app/Services/Care/Fertility/FertilityAnalyticsService.php create mode 100644 app/Services/Care/Fertility/FertilityWorkflowService.php create mode 100644 app/Services/Care/Podiatry/PodiatryAnalyticsService.php create mode 100644 app/Services/Care/Podiatry/PodiatryWorkflowService.php create mode 100644 resources/views/care/specialty/dermatology/print.blade.php create mode 100644 resources/views/care/specialty/dermatology/reports.blade.php create mode 100644 resources/views/care/specialty/dermatology/workspace-overview.blade.php create mode 100644 resources/views/care/specialty/dermatology/workspace-procedure.blade.php create mode 100644 resources/views/care/specialty/fertility/print.blade.php create mode 100644 resources/views/care/specialty/fertility/reports.blade.php create mode 100644 resources/views/care/specialty/fertility/workspace-cycle.blade.php create mode 100644 resources/views/care/specialty/fertility/workspace-overview.blade.php create mode 100644 resources/views/care/specialty/podiatry/print.blade.php create mode 100644 resources/views/care/specialty/podiatry/reports.blade.php create mode 100644 resources/views/care/specialty/podiatry/workspace-overview.blade.php create mode 100644 resources/views/care/specialty/podiatry/workspace-procedure.blade.php create mode 100644 tests/Feature/CareDermatologySuiteTest.php create mode 100644 tests/Feature/CareFertilitySuiteTest.php create mode 100644 tests/Feature/CarePodiatrySuiteTest.php diff --git a/app/Http/Controllers/Care/DermatologyWorkspaceController.php b/app/Http/Controllers/Care/DermatologyWorkspaceController.php new file mode 100644 index 0000000..e678930 --- /dev/null +++ b/app/Http/Controllers/Care/DermatologyWorkspaceController.php @@ -0,0 +1,215 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'dermatology'), 403); + } + + protected function assertDermatologyManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'dermatology'), 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->assertDermatologyManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'dermatology', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'dermatology', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('dermatology', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveProcedure( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + DermatologyWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDermatologyManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('dermatology', 'derm_procedure') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'procedure']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('dermatology', 'derm_procedure'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'dermatology', + 'derm_procedure', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + DermatologyWorkflowService::STAGE_PROCEDURE, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'dermatology', + DermatologyWorkflowService::STAGE_PROCEDURE, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'dermatology', + DermatologyWorkflowService::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' => 'dermatology', 'visit' => $visit, 'tab' => 'procedure']) + ->with('success', 'Procedure / treatment saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + DermatologyAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertDermatologyAccess($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.dermatology.reports', [ + 'moduleKey' => 'dermatology', + 'definition' => $modules->definition('dermatology'), + 'shellNav' => $shell->navItems('dermatology'), + '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->assertDermatologyAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.dermatology.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'history' => $clinical->findForVisit($visit, 'dermatology', 'derm_history'), + 'exam' => $clinical->findForVisit($visit, 'dermatology', 'skin_exam'), + 'investigation' => $clinical->findForVisit($visit, 'dermatology', 'derm_investigation'), + 'plan' => $clinical->findForVisit($visit, 'dermatology', 'derm_plan'), + 'procedure' => $clinical->findForVisit($visit, 'dermatology', 'derm_procedure'), + ]); + } +} diff --git a/app/Http/Controllers/Care/FertilityWorkspaceController.php b/app/Http/Controllers/Care/FertilityWorkspaceController.php new file mode 100644 index 0000000..c97136b --- /dev/null +++ b/app/Http/Controllers/Care/FertilityWorkspaceController.php @@ -0,0 +1,215 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'fertility'), 403); + } + + protected function assertFertilityManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'fertility'), 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->assertFertilityManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'fertility', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'fertility', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('fertility', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveCycle( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + FertilityWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertFertilityManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('fertility', 'fert_cycle') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'cycle']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('fertility', 'fert_cycle'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'fertility', + 'fert_cycle', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + FertilityWorkflowService::STAGE_CYCLE, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'fertility', + FertilityWorkflowService::STAGE_CYCLE, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'fertility', + FertilityWorkflowService::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' => 'fertility', 'visit' => $visit, 'tab' => 'cycle']) + ->with('success', 'Cycle / procedure note saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + FertilityAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertFertilityAccess($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.fertility.reports', [ + 'moduleKey' => 'fertility', + 'definition' => $modules->definition('fertility'), + 'shellNav' => $shell->navItems('fertility'), + '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->assertFertilityAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.fertility.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'history' => $clinical->findForVisit($visit, 'fertility', 'fert_history'), + 'exam' => $clinical->findForVisit($visit, 'fertility', 'fert_exam'), + 'investigation' => $clinical->findForVisit($visit, 'fertility', 'fert_investigation'), + 'plan' => $clinical->findForVisit($visit, 'fertility', 'fert_plan'), + 'cycle' => $clinical->findForVisit($visit, 'fertility', 'fert_cycle'), + ]); + } +} diff --git a/app/Http/Controllers/Care/PodiatryWorkspaceController.php b/app/Http/Controllers/Care/PodiatryWorkspaceController.php new file mode 100644 index 0000000..0fd52f8 --- /dev/null +++ b/app/Http/Controllers/Care/PodiatryWorkspaceController.php @@ -0,0 +1,215 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'podiatry'), 403); + } + + protected function assertPodiatryManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'podiatry'), 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->assertPodiatryManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'podiatry', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'podiatry', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('podiatry', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveProcedure( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + PodiatryWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertPodiatryManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('podiatry', 'pod_procedure') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'procedure']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('podiatry', 'pod_procedure'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'podiatry', + 'pod_procedure', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + PodiatryWorkflowService::STAGE_PROCEDURE, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'podiatry', + PodiatryWorkflowService::STAGE_PROCEDURE, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'podiatry', + PodiatryWorkflowService::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' => 'podiatry', 'visit' => $visit, 'tab' => 'procedure']) + ->with('success', 'Procedure saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + PodiatryAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertPodiatryAccess($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.podiatry.reports', [ + 'moduleKey' => 'podiatry', + 'definition' => $modules->definition('podiatry'), + 'shellNav' => $shell->navItems('podiatry'), + '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->assertPodiatryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.podiatry.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'history' => $clinical->findForVisit($visit, 'podiatry', 'pod_history'), + 'exam' => $clinical->findForVisit($visit, 'podiatry', 'foot_exam'), + 'investigation' => $clinical->findForVisit($visit, 'podiatry', 'pod_investigation'), + 'plan' => $clinical->findForVisit($visit, 'podiatry', 'pod_plan'), + 'procedure' => $clinical->findForVisit($visit, 'podiatry', 'pod_procedure'), + ]); + } +} diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index c8fffc2..775e049 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -168,6 +168,9 @@ class SpecialtyModuleController extends Controller 'vaccination' => 'eligibility', 'pathology' => 'request', 'infusion' => 'assessment', + 'dermatology' => 'history', + 'podiatry' => 'history', + 'fertility' => 'history', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -254,6 +257,9 @@ class SpecialtyModuleController extends Controller 'vaccination' => 'eligibility', 'pathology' => 'request', 'infusion' => 'assessment', + 'dermatology' => 'history', + 'podiatry' => 'history', + 'fertility' => 'history', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -1019,6 +1025,108 @@ class SpecialtyModuleController extends Controller } } + if ($module === 'dermatology' && in_array($recordType, ['derm_history', 'skin_exam', 'derm_investigation', 'derm_plan'], true)) { + $workflow = app(\App\Services\Care\Dermatology\DermatologyWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'derm_history' => $workflow->stageFromHistory($record->payload ?? []), + 'skin_exam' => $workflow->stageFromExam($record->payload ?? []), + 'derm_investigation' => $workflow->stageFromInvestigation($record->payload ?? []), + 'derm_plan' => $workflow->stageFromPlan($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'history' => 1, + 'exam' => 2, + 'investigation' => 3, + 'plan' => 4, + 'procedure' => 5, + 'completed' => 6, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage($organization, $visit, 'dermatology', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage($organization, $visit, 'dermatology', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } + } + + if ($module === 'podiatry' && in_array($recordType, ['pod_history', 'foot_exam', 'pod_investigation', 'pod_plan'], true)) { + $workflow = app(\App\Services\Care\Podiatry\PodiatryWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'pod_history' => $workflow->stageFromHistory($record->payload ?? []), + 'foot_exam' => $workflow->stageFromExam($record->payload ?? []), + 'pod_investigation' => $workflow->stageFromInvestigation($record->payload ?? []), + 'pod_plan' => $workflow->stageFromPlan($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'history' => 1, + 'exam' => 2, + 'investigation' => 3, + 'plan' => 4, + 'procedure' => 5, + 'completed' => 6, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage($organization, $visit, 'podiatry', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage($organization, $visit, 'podiatry', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } + } + + if ($module === 'fertility' && in_array($recordType, ['fert_history', 'fert_exam', 'fert_investigation', 'fert_plan'], true)) { + $workflow = app(\App\Services\Care\Fertility\FertilityWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'fert_history' => $workflow->stageFromHistory($record->payload ?? []), + 'fert_exam' => $workflow->stageFromExam($record->payload ?? []), + 'fert_investigation' => $workflow->stageFromInvestigation($record->payload ?? []), + 'fert_plan' => $workflow->stageFromPlan($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'history' => 1, + 'exam' => 2, + 'investigation' => 3, + 'plan' => 4, + 'cycle' => 5, + 'completed' => 6, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage($organization, $visit, 'fertility', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage($organization, $visit, 'fertility', $suggested, $this->ownerRef($request), $this->ownerRef($request)); + } catch (\InvalidArgumentException) { + } + } + } + return redirect() ->route('care.specialty.workspace', [ 'module' => $module, @@ -1515,6 +1623,27 @@ class SpecialtyModuleController extends Controller $infusionMonitoring = null; $infusionStageCodes = []; $infusionStageFlow = []; + $dermatologyHistory = null; + $dermatologyExam = null; + $dermatologyInvestigation = null; + $dermatologyPlan = null; + $dermatologyProcedure = null; + $dermatologyStageCodes = []; + $dermatologyStageFlow = []; + $podiatryHistory = null; + $podiatryExam = null; + $podiatryInvestigation = null; + $podiatryPlan = null; + $podiatryProcedure = null; + $podiatryStageCodes = []; + $podiatryStageFlow = []; + $fertilityHistory = null; + $fertilityExam = null; + $fertilityInvestigation = null; + $fertilityPlan = null; + $fertilityCycle = null; + $fertilityStageCodes = []; + $fertilityStageFlow = []; $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); $clinical = app(SpecialtyClinicalRecordService::class); @@ -1767,6 +1896,36 @@ class SpecialtyModuleController extends Controller $infusionStageFlow = app(\App\Services\Care\Infusion\InfusionWorkflowService::class)->stageFlow(); } + if ($module === 'dermatology') { + $dermatologyHistory = $clinical->findForVisit($workspaceVisit, 'dermatology', 'derm_history'); + $dermatologyExam = $clinical->findForVisit($workspaceVisit, 'dermatology', 'skin_exam'); + $dermatologyInvestigation = $clinical->findForVisit($workspaceVisit, 'dermatology', 'derm_investigation'); + $dermatologyPlan = $clinical->findForVisit($workspaceVisit, 'dermatology', 'derm_plan'); + $dermatologyProcedure = $clinical->findForVisit($workspaceVisit, 'dermatology', 'derm_procedure'); + $dermatologyStageCodes = collect($shell->stages('dermatology'))->pluck('code')->all(); + $dermatologyStageFlow = app(\App\Services\Care\Dermatology\DermatologyWorkflowService::class)->stageFlow(); + } + + if ($module === 'podiatry') { + $podiatryHistory = $clinical->findForVisit($workspaceVisit, 'podiatry', 'pod_history'); + $podiatryExam = $clinical->findForVisit($workspaceVisit, 'podiatry', 'foot_exam'); + $podiatryInvestigation = $clinical->findForVisit($workspaceVisit, 'podiatry', 'pod_investigation'); + $podiatryPlan = $clinical->findForVisit($workspaceVisit, 'podiatry', 'pod_plan'); + $podiatryProcedure = $clinical->findForVisit($workspaceVisit, 'podiatry', 'pod_procedure'); + $podiatryStageCodes = collect($shell->stages('podiatry'))->pluck('code')->all(); + $podiatryStageFlow = app(\App\Services\Care\Podiatry\PodiatryWorkflowService::class)->stageFlow(); + } + + if ($module === 'fertility') { + $fertilityHistory = $clinical->findForVisit($workspaceVisit, 'fertility', 'fert_history'); + $fertilityExam = $clinical->findForVisit($workspaceVisit, 'fertility', 'fert_exam'); + $fertilityInvestigation = $clinical->findForVisit($workspaceVisit, 'fertility', 'fert_investigation'); + $fertilityPlan = $clinical->findForVisit($workspaceVisit, 'fertility', 'fert_plan'); + $fertilityCycle = $clinical->findForVisit($workspaceVisit, 'fertility', 'fert_cycle'); + $fertilityStageCodes = collect($shell->stages('fertility'))->pluck('code')->all(); + $fertilityStageFlow = app(\App\Services\Care\Fertility\FertilityWorkflowService::class)->stageFlow(); + } + if ($patientHeader !== null) { $patientHeader['clinical_alerts'] = $clinicalAlerts; } @@ -1955,6 +2114,27 @@ class SpecialtyModuleController extends Controller 'infusionMonitoring' => $infusionMonitoring, 'infusionStageCodes' => $infusionStageCodes, 'infusionStageFlow' => $infusionStageFlow, + 'dermatologyHistory' => $dermatologyHistory, + 'dermatologyExam' => $dermatologyExam, + 'dermatologyInvestigation' => $dermatologyInvestigation, + 'dermatologyPlan' => $dermatologyPlan, + 'dermatologyProcedure' => $dermatologyProcedure, + 'dermatologyStageCodes' => $dermatologyStageCodes, + 'dermatologyStageFlow' => $dermatologyStageFlow, + 'podiatryHistory' => $podiatryHistory, + 'podiatryExam' => $podiatryExam, + 'podiatryInvestigation' => $podiatryInvestigation, + 'podiatryPlan' => $podiatryPlan, + 'podiatryProcedure' => $podiatryProcedure, + 'podiatryStageCodes' => $podiatryStageCodes, + 'podiatryStageFlow' => $podiatryStageFlow, + 'fertilityHistory' => $fertilityHistory, + 'fertilityExam' => $fertilityExam, + 'fertilityInvestigation' => $fertilityInvestigation, + 'fertilityPlan' => $fertilityPlan, + 'fertilityCycle' => $fertilityCycle, + 'fertilityStageCodes' => $fertilityStageCodes, + 'fertilityStageFlow' => $fertilityStageFlow, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'branchLabel' => $branchLabel, diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 4227d23..f3fd3a5 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -1029,6 +1029,9 @@ class DemoTenantSeeder $this->seedVaccinationClinicalDemo($organization, $ownerRef); $this->seedPathologyClinicalDemo($organization, $ownerRef); $this->seedInfusionClinicalDemo($organization, $ownerRef); + $this->seedDermatologyClinicalDemo($organization, $ownerRef); + $this->seedPodiatryClinicalDemo($organization, $ownerRef); + $this->seedFertilityClinicalDemo($organization, $ownerRef); return $count; } @@ -2669,6 +2672,344 @@ class DemoTenantSeeder } } + private function seedDermatologyClinicalDemo(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', 'dermatology') + ->where('is_active', true) + ->pluck('id'); + + if ($departmentIds->isEmpty()) { + return; + } + + $appointments = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('department_id', $departmentIds) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->whereNotNull('visit_id') + ->with('visit') + ->orderBy('branch_id') + ->orderBy('id') + ->get(); + + if ($appointments->isEmpty()) { + return; + } + + $clinical = app(SpecialtyClinicalRecordService::class); + $index = 0; + + foreach ($appointments as $appointment) { + $visit = $appointment->visit; + if (! $visit) { + continue; + } + + $urgent = $index === 0; + $clinical->upsert( + $organization, + $visit, + 'dermatology', + 'derm_history', + [ + 'history' => $urgent + ? 'Changing pigmented lesion on forearm noticed by partner' + : 'Itchy rash on elbows for 6 weeks', + 'past_skin' => $urgent ? 'Prior atypical naevus excised 2018' : 'Childhood eczema', + 'medications' => $urgent ? 'None' : 'Emollients PRN', + 'allergies' => 'NKDA', + 'triggers' => $urgent ? 'Sun exposure' : 'Dry weather; detergents', + 'family_history' => $urgent ? 'Mother melanoma' : 'None relevant', + ], + $ownerRef, + $ownerRef, + 'history', + ); + + $clinical->upsert( + $organization, + $visit, + 'dermatology', + 'skin_exam', + [ + 'chief_complaint' => $urgent ? 'Changing mole' : 'Elbow rash', + 'urgency' => $urgent ? 'Suspected malignancy' : 'Routine', + 'duration' => $urgent ? '3 months' : '6 weeks', + 'distribution' => $urgent ? 'Left forearm' : 'Bilateral elbows', + 'morphology' => $urgent + ? 'Asymmetric pigmented plaque with irregular border' + : 'Erythematous plaques with silvery scale', + 'itch_pain' => $urgent ? 'None' : 'Moderate itch', + 'dermoscopy' => $urgent ? 'Atypical pigment network' : 'Regular scale pattern', + 'differential' => $urgent ? 'Melanoma vs atypical naevus' : 'Psoriasis vs eczema', + ], + $ownerRef, + $ownerRef, + 'exam', + ); + + if ($urgent) { + $clinical->upsert( + $organization, + $visit, + 'dermatology', + 'derm_plan', + [ + 'diagnosis' => 'Suspicious pigmented lesion — rule out melanoma', + 'plan' => 'Excision biopsy; sun protection counselling', + 'medications' => 'None pending histology', + 'procedure_planned' => true, + 'follow_up' => 'Histology results in 10–14 days', + 'advice' => 'Avoid sunburn; photograph lesion site', + ], + $ownerRef, + $ownerRef, + 'plan', + ); + } + + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $urgent ? 'plan' : 'exam']); + } + + $index++; + } + } + + private function seedPodiatryClinicalDemo(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', 'podiatry') + ->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, + 'podiatry', + 'pod_history', + [ + 'history' => $highRisk + ? 'Non-healing plantar ulcer for 3 weeks' + : 'Ingrown toenail with intermittent pain', + 'diabetes_history' => $highRisk ? 'Type 2 DM 12 years; peripheral neuropathy' : 'No diabetes', + 'medications' => $highRisk ? 'Metformin, insulin' : 'None', + 'allergies' => 'NKDA', + 'footwear' => $highRisk ? 'Ill-fitting sandals' : 'Closed shoes at work', + 'prior_ulcers' => $highRisk ? 'Prior healed ulcer right foot 2022' : 'None', + ], + $ownerRef, + $ownerRef, + 'history', + ); + + $clinical->upsert( + $organization, + $visit, + 'podiatry', + 'foot_exam', + [ + 'chief_complaint' => $highRisk ? 'Plantar ulcer' : 'Ingrown toenail', + 'side' => $highRisk ? 'Right' : 'Left', + 'diabetes' => $highRisk ? 'Active ulcer' : 'N/A', + 'pulses' => $highRisk ? 'Dorsalis pedis weak' : 'Present bilaterally', + 'sensation' => $highRisk ? 'Reduced monofilament' : 'Intact', + 'skin_nails' => $highRisk ? 'Callus surrounding ulcer' : 'Medial nail fold inflamed', + 'ulcer' => $highRisk ? '2 cm plantar ulcer, clean base' : '', + 'offloading' => $highRisk ? 'Felt pad interim' : 'Wide toe box advised', + ], + $ownerRef, + $ownerRef, + 'exam', + ); + + if ($highRisk) { + $clinical->upsert( + $organization, + $visit, + 'podiatry', + 'pod_plan', + [ + 'diagnosis' => 'Diabetic foot ulcer — high risk', + 'plan' => 'Debridement; offloading; wound care; vascular review if needed', + 'offloading' => 'Total contact cast or removable boot', + 'medications' => 'Dressings; analgesia as needed', + 'procedure_planned' => true, + 'follow_up' => '1 week wound clinic', + 'advice' => 'Daily foot check; avoid barefoot walking', + ], + $ownerRef, + $ownerRef, + 'plan', + ); + } + + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']); + } + + $index++; + } + } + + private function seedFertilityClinicalDemo(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', 'fertility') + ->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; + } + + $priority = $index === 0; + $clinical->upsert( + $organization, + $visit, + 'fertility', + 'fert_history', + [ + 'history' => $priority + ? 'Trying to conceive 28 months; age 38; prior miscarriage' + : 'Trying to conceive 14 months; regular cycles', + 'trying_months' => $priority ? 28 : 14, + 'obstetric_history' => $priority ? 'G1P0; miscarriage at 8 weeks 2024' : 'G0P0; cycles 28–30 days', + 'partner_notes' => $priority ? 'Partner semen analysis pending' : 'Partner well; no known issues', + 'medications' => 'Folic acid', + 'allergies' => 'NKDA', + ], + $ownerRef, + $ownerRef, + 'history', + ); + + $clinical->upsert( + $organization, + $visit, + 'fertility', + 'fert_exam', + [ + 'chief_complaint' => $priority ? 'Primary infertility — time-sensitive' : 'Fertility workup', + 'priority' => $priority ? 'Time-sensitive' : 'Routine', + 'partner_present' => true, + 'cycle_day' => $priority ? 3 : 10, + 'bmi' => $priority ? 27 : 23, + 'exam_findings' => 'Pelvic exam unremarkable; uterus anteverted', + 'notes' => $priority ? 'Discussed AMH and early referral pathway' : 'Baseline counselling given', + ], + $ownerRef, + $ownerRef, + 'exam', + ); + + if ($priority) { + $clinical->upsert( + $organization, + $visit, + 'fertility', + 'fert_plan', + [ + 'diagnosis' => 'Primary infertility — age-related priority', + 'plan' => 'Complete AMH/hormones; semen analysis; pelvic scan; consider IUI counselling', + 'medications' => 'Continue folic acid; ovulation induction if indicated', + 'cycle_planned' => true, + 'follow_up' => 'Cycle day 3 labs; review with results', + 'advice' => 'Timed intercourse education; lifestyle counselling', + ], + $ownerRef, + $ownerRef, + 'plan', + ); + } + + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $priority ? '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/Dermatology/DermatologyAnalyticsService.php b/app/Services/Care/Dermatology/DermatologyAnalyticsService.php new file mode 100644 index 0000000..1288d02 --- /dev/null +++ b/app/Services/Care/Dermatology/DermatologyAnalyticsService.php @@ -0,0 +1,168 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'dermatology', $ownerRef, $branchId); + + $appointmentBase = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds)) + ->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0')) + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)); + + $arrivalsToday = (clone $appointmentBase) + ->where('checked_in_at', '>=', $todayStart) + ->count(); + + $completedToday = (clone $appointmentBase) + ->where('status', Appointment::STATUS_COMPLETED) + ->where('completed_at', '>=', $todayStart) + ->count(); + + $visitIds = (clone $appointmentBase)->whereNotNull('visit_id')->pluck('visit_id'); + + $openVisitIds = Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->pluck('id'); + + $urgentOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'dermatology') + ->where('record_type', 'skin_exam') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(function (SpecialtyClinicalRecord $r) { + $urgency = (string) ($r->payload['urgency'] ?? ''); + + return in_array($urgency, ['High', 'Suspected malignancy'], true); + }) + ->count(); + + $planRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'dermatology') + ->where('record_type', 'derm_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', 'dermatology') + ->where('record_type', 'derm_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, 'dermatology')) + ->pluck('label') + ->filter() + ->values() + ->all(); + + $revenueByService = BillLineItem::query() + ->where('owner_ref', $ownerRef) + ->where('source_type', 'specialty_service') + ->whereIn('description', $serviceLabels ?: ['__none__']) + ->whereBetween('created_at', [$rangeFrom, $rangeTo]) + ->whereHas('bill', function ($q) use ($organization, $visitIds) { + $q->where('organization_id', $organization->id) + ->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds) + ->whereNotIn('status', [Bill::STATUS_VOID]); + }) + ->selectRaw('description, count(*) as total, sum(total_minor) as amount_minor') + ->groupBy('description') + ->orderByDesc('amount_minor') + ->get(); + + return [ + 'arrivals_today' => $arrivalsToday, + 'completed_today' => $completedToday, + 'urgent_open' => $urgentOpen, + '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/Dermatology/DermatologyWorkflowService.php b/app/Services/Care/Dermatology/DermatologyWorkflowService.php new file mode 100644 index 0000000..6838b40 --- /dev/null +++ b/app/Services/Care/Dermatology/DermatologyWorkflowService.php @@ -0,0 +1,104 @@ + $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['morphology']) || ! empty($payload['chief_complaint'])) { + 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 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_EXAM, 'label' => 'Move to skin exam'], + self::STAGE_EXAM => ['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_PROCEDURE, 'label' => 'Move to procedure'], + self::STAGE_PROCEDURE => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'], + self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'], + ]; + } +} diff --git a/app/Services/Care/Fertility/FertilityAnalyticsService.php b/app/Services/Care/Fertility/FertilityAnalyticsService.php new file mode 100644 index 0000000..68e1591 --- /dev/null +++ b/app/Services/Care/Fertility/FertilityAnalyticsService.php @@ -0,0 +1,168 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'fertility', $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'); + + $priorityOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'fertility') + ->where('record_type', 'fert_exam') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(function (SpecialtyClinicalRecord $r) { + $priority = (string) ($r->payload['priority'] ?? ''); + + return in_array($priority, ['Urgent', 'Time-sensitive'], true); + }) + ->count(); + + $planRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'fertility') + ->where('record_type', 'fert_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(); + + $cycleRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'fertility') + ->where('record_type', 'fert_cycle') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $cycleBreakdown = $cycleRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['cycle_type'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $cycleType) => [ + 'procedure' => $cycleType, + '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, 'fertility')) + ->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, + 'priority_open' => $priorityOpen, + 'diagnosis_breakdown' => $diagnosisBreakdown, + 'cycle_breakdown' => $cycleBreakdown, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/Fertility/FertilityWorkflowService.php b/app/Services/Care/Fertility/FertilityWorkflowService.php new file mode 100644 index 0000000..450b26c --- /dev/null +++ b/app/Services/Care/Fertility/FertilityWorkflowService.php @@ -0,0 +1,104 @@ + $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['exam_findings']) || ! empty($payload['chief_complaint'])) { + 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['cycle_planned'])) { + return self::STAGE_CYCLE; + } + + $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_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 diagnosis & plan'], + self::STAGE_PLAN => ['next' => self::STAGE_CYCLE, 'label' => 'Move to cycle note'], + self::STAGE_CYCLE => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'], + self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'], + ]; + } +} diff --git a/app/Services/Care/Podiatry/PodiatryAnalyticsService.php b/app/Services/Care/Podiatry/PodiatryAnalyticsService.php new file mode 100644 index 0000000..2411ee8 --- /dev/null +++ b/app/Services/Care/Podiatry/PodiatryAnalyticsService.php @@ -0,0 +1,168 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'podiatry', $ownerRef, $branchId); + + $appointmentBase = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds)) + ->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0')) + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)); + + $arrivalsToday = (clone $appointmentBase) + ->where('checked_in_at', '>=', $todayStart) + ->count(); + + $completedToday = (clone $appointmentBase) + ->where('status', Appointment::STATUS_COMPLETED) + ->where('completed_at', '>=', $todayStart) + ->count(); + + $visitIds = (clone $appointmentBase)->whereNotNull('visit_id')->pluck('visit_id'); + + $openVisitIds = Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->pluck('id'); + + $highRiskOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'podiatry') + ->where('record_type', 'foot_exam') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(function (SpecialtyClinicalRecord $r) { + $risk = (string) ($r->payload['diabetes'] ?? ''); + + return in_array($risk, ['High', 'Active ulcer'], true); + }) + ->count(); + + $planRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'podiatry') + ->where('record_type', 'pod_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', 'podiatry') + ->where('record_type', 'pod_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, 'podiatry')) + ->pluck('label') + ->filter() + ->values() + ->all(); + + $revenueByService = BillLineItem::query() + ->where('owner_ref', $ownerRef) + ->where('source_type', 'specialty_service') + ->whereIn('description', $serviceLabels ?: ['__none__']) + ->whereBetween('created_at', [$rangeFrom, $rangeTo]) + ->whereHas('bill', function ($q) use ($organization, $visitIds) { + $q->where('organization_id', $organization->id) + ->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds) + ->whereNotIn('status', [Bill::STATUS_VOID]); + }) + ->selectRaw('description, count(*) as total, sum(total_minor) as amount_minor') + ->groupBy('description') + ->orderByDesc('amount_minor') + ->get(); + + return [ + 'arrivals_today' => $arrivalsToday, + 'completed_today' => $completedToday, + 'high_risk_open' => $highRiskOpen, + '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/Podiatry/PodiatryWorkflowService.php b/app/Services/Care/Podiatry/PodiatryWorkflowService.php new file mode 100644 index 0000000..9ac7ffb --- /dev/null +++ b/app/Services/Care/Podiatry/PodiatryWorkflowService.php @@ -0,0 +1,104 @@ + $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['ulcer']) || ! empty($payload['skin_nails'])) { + 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 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_EXAM, 'label' => 'Move to foot exam'], + self::STAGE_EXAM => ['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_PROCEDURE, 'label' => 'Move to procedure'], + self::STAGE_PROCEDURE => ['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 fc00591..1f56a6e 100644 --- a/app/Services/Care/SpecialtyClinicalRecordService.php +++ b/app/Services/Care/SpecialtyClinicalRecordService.php @@ -419,6 +419,39 @@ class SpecialtyClinicalRecordService } } + if ($moduleKey === 'dermatology' && $recordType === 'skin_exam') { + $urgency = (string) ($payload['urgency'] ?? ''); + if (in_array($urgency, ['High', 'Suspected malignancy'], true)) { + $alerts[] = [ + 'code' => 'der.urgency_high', + 'severity' => $urgency === 'Suspected malignancy' ? 'critical' : 'warning', + 'message' => 'Skin exam urgency: '.$urgency.'.', + ]; + } + } + + if ($moduleKey === 'podiatry' && $recordType === 'foot_exam') { + $risk = (string) ($payload['diabetes'] ?? ''); + if (in_array($risk, ['High', 'Active ulcer'], true)) { + $alerts[] = [ + 'code' => 'pod.diabetes_high', + 'severity' => $risk === 'Active ulcer' ? 'critical' : 'warning', + 'message' => 'Diabetic foot risk: '.$risk.'.', + ]; + } + } + + if ($moduleKey === 'fertility' && $recordType === 'fert_exam') { + $priority = (string) ($payload['priority'] ?? ''); + if (in_array($priority, ['Urgent', 'Time-sensitive'], true)) { + $alerts[] = [ + 'code' => 'fer.priority_high', + 'severity' => $priority === 'Urgent' ? 'critical' : 'warning', + 'message' => 'Fertility priority: '.$priority.'.', + ]; + } + } + if ($moduleKey === 'pediatrics' && $recordType === 'pediatric_exam') { $concern = (string) ($payload['growth_concern'] ?? ''); if ($concern !== '' && $concern !== 'None') { diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php index 4fc49ec..06ed6f4 100644 --- a/app/Services/Care/SpecialtyShellService.php +++ b/app/Services/Care/SpecialtyShellService.php @@ -26,6 +26,9 @@ use App\Services\Care\Surgery\SurgeryWorkflowService; use App\Services\Care\Vaccination\VaccinationWorkflowService; use App\Services\Care\Pathology\PathologyWorkflowService; use App\Services\Care\Infusion\InfusionWorkflowService; +use App\Services\Care\Dermatology\DermatologyWorkflowService; +use App\Services\Care\Podiatry\PodiatryWorkflowService; +use App\Services\Care\Fertility\FertilityWorkflowService; use Illuminate\Support\Collection; /** @@ -84,6 +87,9 @@ class SpecialtyShellService 'vaccination' => 'care.specialty.vaccination.stage', 'pathology' => 'care.specialty.pathology.stage', 'infusion' => 'care.specialty.infusion.stage', + 'dermatology' => 'care.specialty.dermatology.stage', + 'podiatry' => 'care.specialty.podiatry.stage', + 'fertility' => 'care.specialty.fertility.stage', default => null, }; } @@ -113,6 +119,9 @@ class SpecialtyShellService 'vaccination' => app(VaccinationWorkflowService::class)->stageFlow(), 'pathology' => app(PathologyWorkflowService::class)->stageFlow(), 'infusion' => app(InfusionWorkflowService::class)->stageFlow(), + 'dermatology' => app(DermatologyWorkflowService::class)->stageFlow(), + 'podiatry' => app(PodiatryWorkflowService::class)->stageFlow(), + 'fertility' => app(FertilityWorkflowService::class)->stageFlow(), 'dentistry' => [ 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], @@ -412,7 +421,7 @@ class SpecialtyShellService ) { $code = (string) ($stage['code'] ?? ''); - if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery', 'vaccination', 'pathology', 'infusion'], true) && $visitIds->isNotEmpty()) { + if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent', 'oncology', 'renal', 'surgery', 'vaccination', 'pathology', 'infusion', 'dermatology', 'podiatry', 'fertility'], true) && $visitIds->isNotEmpty()) { $count = Visit::owned($ownerRef) ->where('organization_id', $organization->id) ->whereIn('id', $visitIds) @@ -457,6 +466,9 @@ class SpecialtyShellService 'vaccination' => $clinical->countOpenByType($organization, 'vaccination', 'vax_eligibility', $branchScope), 'pathology' => $clinical->countOpenByType($organization, 'pathology', 'path_request', $branchScope), 'infusion' => $clinical->countOpenByType($organization, 'infusion', 'infusion_assessment', $branchScope), + 'dermatology' => $clinical->countOpenByType($organization, 'dermatology', 'skin_exam', $branchScope), + 'podiatry' => $clinical->countOpenByType($organization, 'podiatry', 'foot_exam', $branchScope), + 'fertility' => $clinical->countOpenByType($organization, 'fertility', 'fert_exam', $branchScope), 'dentistry' => \App\Models\DentalPlanItem::query() ->whereHas('plan', function ($q) use ($organization, $ownerRef) { $q->owned($ownerRef) diff --git a/app/Services/Care/SpecialtyVisitStageService.php b/app/Services/Care/SpecialtyVisitStageService.php index 9274942..4d59c35 100644 --- a/app/Services/Care/SpecialtyVisitStageService.php +++ b/app/Services/Care/SpecialtyVisitStageService.php @@ -168,6 +168,30 @@ class SpecialtyVisitStageService : ($stages[1] ?? ($stages[0] ?? null)); } + if ($moduleKey === 'dermatology') { + $stages = $this->allowedStages($moduleKey); + + return in_array(\App\Services\Care\Dermatology\DermatologyWorkflowService::STAGE_HISTORY, $stages, true) + ? \App\Services\Care\Dermatology\DermatologyWorkflowService::STAGE_HISTORY + : ($stages[1] ?? ($stages[0] ?? null)); + } + + if ($moduleKey === 'podiatry') { + $stages = $this->allowedStages($moduleKey); + + return in_array(\App\Services\Care\Podiatry\PodiatryWorkflowService::STAGE_HISTORY, $stages, true) + ? \App\Services\Care\Podiatry\PodiatryWorkflowService::STAGE_HISTORY + : ($stages[1] ?? ($stages[0] ?? null)); + } + + if ($moduleKey === 'fertility') { + $stages = $this->allowedStages($moduleKey); + + return in_array(\App\Services\Care\Fertility\FertilityWorkflowService::STAGE_HISTORY, $stages, true) + ? \App\Services\Care\Fertility\FertilityWorkflowService::STAGE_HISTORY + : ($stages[1] ?? ($stages[0] ?? null)); + } + $stages = $this->allowedStages($moduleKey); foreach (['chair', 'in_care', 'exam', 'assessment', 'treatment'] as $preferred) { if (in_array($preferred, $stages, true)) { diff --git a/config/care_specialty_clinical.php b/config/care_specialty_clinical.php index 8cfbc10..b20220d 100644 --- a/config/care_specialty_clinical.php +++ b/config/care_specialty_clinical.php @@ -127,16 +127,25 @@ return [ 'monitoring' => 'infusion_monitoring', ], 'dermatology' => [ + 'history' => 'derm_history', 'exam' => 'skin_exam', - 'clinical_notes' => 'clinical_note', + 'investigations' => 'derm_investigation', + 'plan' => 'derm_plan', + 'procedure' => 'derm_procedure', ], 'podiatry' => [ + 'history' => 'pod_history', 'exam' => 'foot_exam', - 'clinical_notes' => 'clinical_note', + 'investigations' => 'pod_investigation', + 'plan' => 'pod_plan', + 'procedure' => 'pod_procedure', ], 'fertility' => [ - 'consult' => 'fertility_consult', - 'clinical_notes' => 'clinical_note', + 'history' => 'fert_history', + 'exam' => 'fert_exam', + 'investigations' => 'fert_investigation', + 'plan' => 'fert_plan', + 'cycle' => 'fert_cycle', ], 'child_welfare' => [ 'growth' => 'growth', @@ -883,23 +892,57 @@ return [ ], ], 'dermatology' => [ + 'derm_history' => [ + ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4], + ['name' => 'past_skin', 'label' => 'Past skin history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'], + ['name' => 'triggers', 'label' => 'Triggers / exposures', 'type' => 'textarea', 'rows' => 2], + ['name' => 'family_history', 'label' => 'Family history', 'type' => 'textarea', 'rows' => 2], + ], 'skin_exam' => [ ['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true], + ['name' => 'urgency', 'label' => 'Urgency', 'type' => 'select', 'options' => ['Routine', 'Moderate', 'High', 'Suspected malignancy']], ['name' => 'duration', 'label' => 'Duration', 'type' => 'text'], ['name' => 'distribution', 'label' => 'Distribution', 'type' => 'text'], - ['name' => 'morphology', 'label' => 'Lesion morphology', 'type' => 'textarea', 'required' => true, 'rows' => 2], + ['name' => 'morphology', 'label' => 'Lesion morphology / notes', 'type' => 'textarea', 'required' => true, 'rows' => 2], ['name' => 'itch_pain', 'label' => 'Itch / pain', 'type' => 'text'], ['name' => 'dermoscopy', 'label' => 'Dermoscopy notes', 'type' => 'textarea', 'rows' => 2], ['name' => 'differential', 'label' => 'Differential diagnosis', 'type' => 'textarea', '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], + 'derm_investigation' => [ + ['name' => 'modality', 'label' => 'Investigation', 'type' => 'select', 'required' => true, 'options' => ['Biopsy', 'Patch test', 'Culture', 'Bloods', 'Imaging', 'Other']], + ['name' => 'indication', 'label' => 'Indication', 'type' => 'textarea', 'rows' => 2], + ['name' => 'findings', 'label' => 'Findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'impression', 'label' => 'Impression', 'type' => 'textarea', 'rows' => 2], + ['name' => 'recommendations', 'label' => 'Recommendations', 'type' => 'textarea', 'rows' => 2], + ], + 'derm_plan' => [ + ['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true], + ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'medications', 'label' => 'Medications / topicals', 'type' => 'textarea', 'rows' => 2], + ['name' => 'procedure_planned', 'label' => 'Procedure / treatment planned', 'type' => 'boolean'], + ['name' => 'follow_up', 'label' => 'Follow-up', 'type' => 'text'], + ['name' => 'advice', 'label' => 'Patient advice', 'type' => 'textarea', 'rows' => 2], + ], + 'derm_procedure' => [ + ['name' => 'procedure', 'label' => 'Procedure / treatment', 'type' => 'text', 'required' => true], + ['name' => 'indication', 'label' => 'Indication', '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' => 'post_procedure_plan', 'label' => 'Post-procedure plan', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], ], ], 'podiatry' => [ + 'pod_history' => [ + ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4], + ['name' => 'diabetes_history', 'label' => 'Diabetes / vascular history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'], + ['name' => 'footwear', 'label' => 'Footwear / activity', 'type' => 'textarea', 'rows' => 2], + ['name' => 'prior_ulcers', 'label' => 'Prior ulcers / amputations', 'type' => 'textarea', 'rows' => 2], + ], 'foot_exam' => [ ['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true], ['name' => 'side', 'label' => 'Side', 'type' => 'select', 'options' => ['Left', 'Right', 'Bilateral']], @@ -910,28 +953,71 @@ return [ ['name' => 'ulcer', 'label' => 'Ulcer description', 'type' => 'textarea', 'rows' => 2], ['name' => 'offloading', 'label' => 'Offloading / footwear', 'type' => 'text'], ], - '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], + 'pod_investigation' => [ + ['name' => 'modality', 'label' => 'Investigation', 'type' => 'select', 'required' => true, 'options' => ['X-ray', 'Doppler', 'Labs / CRP', 'Wound swab', 'ABI', 'Other']], + ['name' => 'indication', 'label' => 'Indication', 'type' => 'textarea', 'rows' => 2], + ['name' => 'findings', 'label' => 'Findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'impression', 'label' => 'Impression', 'type' => 'textarea', 'rows' => 2], + ['name' => 'recommendations', 'label' => 'Recommendations', 'type' => 'textarea', 'rows' => 2], + ], + 'pod_plan' => [ + ['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true], + ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'offloading', 'label' => 'Offloading / footwear plan', 'type' => 'textarea', 'rows' => 2], + ['name' => 'medications', 'label' => 'Medications / dressings', 'type' => 'textarea', 'rows' => 2], + ['name' => 'procedure_planned', 'label' => 'Procedure planned', 'type' => 'boolean'], + ['name' => 'follow_up', 'label' => 'Follow-up', 'type' => 'text'], + ['name' => 'advice', 'label' => 'Patient advice', 'type' => 'textarea', 'rows' => 2], + ], + 'pod_procedure' => [ + ['name' => 'procedure', 'label' => 'Procedure', 'type' => 'text', 'required' => true], + ['name' => 'indication', 'label' => 'Indication', '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' => 'post_procedure_plan', 'label' => 'Post-procedure plan', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], ], ], 'fertility' => [ - 'fertility_consult' => [ - ['name' => 'partner_present', 'label' => 'Partner present', 'type' => 'boolean'], - ['name' => 'trying_months', 'label' => 'Trying to conceive (months)', 'type' => 'number'], - ['name' => 'cycle_day', 'label' => 'Cycle day', 'type' => 'number'], - ['name' => 'amh', 'label' => 'AMH / ovarian reserve notes', 'type' => 'text'], - ['name' => 'semen_summary', 'label' => 'Semen analysis summary', 'type' => 'textarea', 'rows' => 2], - ['name' => 'tubal_uterine', 'label' => 'Tubal / uterine assessment', 'type' => 'textarea', 'rows' => 2], - ['name' => 'plan', 'label' => 'Fertility plan', 'type' => 'textarea', 'required' => true, 'rows' => 3], - ], - 'clinical_note' => [ + 'fert_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' => 'trying_months', 'label' => 'Trying to conceive (months)', 'type' => 'number'], + ['name' => 'obstetric_history', 'label' => 'Obstetric / menstrual history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'partner_notes', 'label' => 'Partner history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'], + ], + 'fert_exam' => [ + ['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true], + ['name' => 'priority', 'label' => 'Priority', 'type' => 'select', 'options' => ['Routine', 'Time-sensitive', 'Urgent']], + ['name' => 'partner_present', 'label' => 'Partner present', 'type' => 'boolean'], + ['name' => 'cycle_day', 'label' => 'Cycle day', 'type' => 'number'], + ['name' => 'bmi', 'label' => 'BMI', 'type' => 'number'], + ['name' => 'exam_findings', 'label' => 'Exam findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], + ], + 'fert_investigation' => [ + ['name' => 'modality', 'label' => 'Investigation', 'type' => 'select', 'required' => true, 'options' => ['AMH / hormones', 'Semen analysis', 'HSG / tubal', 'Pelvic scan', 'Follicular scan', 'Other']], + ['name' => 'indication', 'label' => 'Indication', 'type' => 'textarea', 'rows' => 2], + ['name' => 'findings', 'label' => 'Findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'impression', 'label' => 'Impression', 'type' => 'textarea', 'rows' => 2], + ['name' => 'recommendations', 'label' => 'Recommendations', 'type' => 'textarea', 'rows' => 2], + ], + 'fert_plan' => [ + ['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true], + ['name' => 'plan', 'label' => 'Fertility plan', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'medications', 'label' => 'Medications / stimulation', 'type' => 'textarea', 'rows' => 2], + ['name' => 'cycle_planned', 'label' => 'Cycle / procedure note planned', 'type' => 'boolean'], + ['name' => 'follow_up', 'label' => 'Follow-up', 'type' => 'text'], + ['name' => 'advice', 'label' => 'Patient advice', 'type' => 'textarea', 'rows' => 2], + ], + 'fert_cycle' => [ + ['name' => 'cycle_type', 'label' => 'Cycle / procedure type', 'type' => 'select', 'required' => true, 'options' => ['Natural cycle review', 'Stimulation monitoring', 'IUI', 'IVF counselling', 'Trigger / luteal', 'Other']], + ['name' => 'cycle_day', 'label' => 'Cycle day', 'type' => 'number'], + ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed — continue', 'Completed uneventfully', 'Deferred', 'Cancelled']], + ['name' => 'notes', 'label' => 'Cycle / procedure notes', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'next_steps', 'label' => 'Next steps', 'type' => 'textarea', 'rows' => 2], + ['name' => 'complications', 'label' => 'Complications', 'type' => 'textarea', 'rows' => 2], ], ], 'child_welfare' => [ diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index 01f8d20..9ba14a8 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -725,62 +725,114 @@ return [ ], 'dermatology' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'history', 'label' => 'History', 'queue_point' => 'waiting'], ['code' => 'exam', 'label' => 'Skin exam', 'queue_point' => 'chair'], - ['code' => 'procedure', 'label' => 'Procedure', 'queue_point' => 'chair'], + ['code' => 'investigation', 'label' => 'Investigations', 'queue_point' => 'chair'], + ['code' => 'plan', 'label' => 'Diagnosis & plan', 'queue_point' => 'chair'], + ['code' => 'procedure', 'label' => 'Procedure / treatment', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ ['code' => 'der.consult', 'label' => 'Dermatology consultation', 'amount_minor' => 6000, 'type' => 'consultation'], + ['code' => 'der.biopsy', 'label' => 'Skin biopsy', 'amount_minor' => 10000, 'type' => 'procedure'], + ['code' => 'der.cryotherapy', 'label' => 'Cryotherapy', 'amount_minor' => 8000, 'type' => 'procedure'], ['code' => 'der.procedure', 'label' => 'Skin procedure', 'amount_minor' => 12000, 'type' => 'procedure'], ], 'workspace_tabs' => [ 'overview' => 'Overview', + 'history' => 'History', 'exam' => 'Skin exam', - 'clinical_notes' => 'Clinical notes', + 'investigations' => 'Investigations', + 'plan' => 'Diagnosis & plan', + 'procedure' => 'Procedure', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'history' => 'history', + 'exam' => 'exam', + 'investigation' => 'investigations', + 'plan' => 'plan', + 'procedure' => 'procedure', + 'completed' => 'overview', + ], ], 'podiatry' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'history', 'label' => 'History', 'queue_point' => 'waiting'], ['code' => 'exam', 'label' => 'Foot exam', 'queue_point' => 'chair'], - ['code' => 'treatment', 'label' => 'Treatment', 'queue_point' => 'chair'], + ['code' => 'investigation', 'label' => 'Investigations', 'queue_point' => 'chair'], + ['code' => 'plan', 'label' => 'Diagnosis & plan', 'queue_point' => 'chair'], + ['code' => 'procedure', 'label' => 'Procedure', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ ['code' => 'pod.consult', 'label' => 'Podiatry consultation', 'amount_minor' => 5000, 'type' => 'consultation'], + ['code' => 'pod.assessment', 'label' => 'Diabetic foot assessment', 'amount_minor' => 4500, 'type' => 'consultation'], + ['code' => 'pod.debridement', 'label' => 'Wound debridement', 'amount_minor' => 9000, 'type' => 'procedure'], ['code' => 'pod.treatment', 'label' => 'Foot treatment', 'amount_minor' => 8000, 'type' => 'procedure'], ], 'workspace_tabs' => [ 'overview' => 'Overview', + 'history' => 'History', 'exam' => 'Foot exam', - 'clinical_notes' => 'Clinical notes', + 'investigations' => 'Investigations', + 'plan' => 'Diagnosis & plan', + 'procedure' => 'Procedure', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'history' => 'history', + 'exam' => 'exam', + 'investigation' => 'investigations', + 'plan' => 'plan', + 'procedure' => 'procedure', + 'completed' => 'overview', + ], ], 'fertility' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], - ['code' => 'consult', 'label' => 'Fertility consult', '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' => 'investigation', 'label' => 'Investigations', 'queue_point' => 'chair'], + ['code' => 'plan', 'label' => 'Diagnosis & plan', 'queue_point' => 'chair'], + ['code' => 'cycle', 'label' => 'Cycle / procedure note', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ ['code' => 'fer.consult', 'label' => 'Fertility consultation', 'amount_minor' => 10000, 'type' => 'consultation'], ['code' => 'fer.scan', 'label' => 'Pelvic / follicular scan', 'amount_minor' => 15000, 'type' => 'imaging'], + ['code' => 'fer.cycle', 'label' => 'Cycle review', 'amount_minor' => 8000, 'type' => 'consultation'], + ['code' => 'fer.iui', 'label' => 'IUI procedure note', 'amount_minor' => 20000, 'type' => 'procedure'], ], 'workspace_tabs' => [ 'overview' => 'Overview', - 'consult' => 'Fertility consult', - 'clinical_notes' => 'Clinical notes', + 'history' => 'History', + 'exam' => 'Exam', + 'investigations' => 'Investigations', + 'plan' => 'Diagnosis & plan', + 'cycle' => 'Cycle note', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'history' => 'history', + 'exam' => 'exam', + 'investigation' => 'investigations', + 'plan' => 'plan', + 'cycle' => 'cycle', + 'completed' => 'overview', + ], ], 'child_welfare' => [ 'stages' => [ diff --git a/resources/views/care/specialty/dermatology/print.blade.php b/resources/views/care/specialty/dermatology/print.blade.php new file mode 100644 index 0000000..f7c9bc4 --- /dev/null +++ b/resources/views/care/specialty/dermatology/print.blade.php @@ -0,0 +1,89 @@ + + + + + Dermatology summary · {{ $patient->fullName() }} + + + +

Back

+ +

{{ $patient->fullName() }}

+

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

+ +

History

+ @if ($history) +
+
HPI
{{ $history->payload['history'] ?? '—' }}
+
Past skin
{{ $history->payload['past_skin'] ?? '—' }}
+
Medications
{{ $history->payload['medications'] ?? '—' }}
+
Allergies
{{ $history->payload['allergies'] ?? '—' }}
+
+ @else +

No history recorded.

+ @endif + +

Skin exam

+ @if ($exam) +
+
Complaint
{{ $exam->payload['chief_complaint'] ?? '—' }}
+
Urgency
{{ $exam->payload['urgency'] ?? '—' }}
+
Distribution
{{ $exam->payload['distribution'] ?? '—' }}
+
Morphology
{{ $exam->payload['morphology'] ?? '—' }}
+
Dermoscopy
{{ $exam->payload['dermoscopy'] ?? '—' }}
+
+ @else +

No exam recorded.

+ @endif + +

Investigations

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

No investigation recorded.

+ @endif + +

Diagnosis & plan

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

No plan recorded.

+ @endif + +

Procedure

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

No procedure recorded.

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

Dermatology reports

+

Arrivals, urgent open cases, diagnoses, procedures, length of stay, and dermatology revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

Urgent open

+

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

+
+
+

Avg LOS (range)

+

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

+
+
+ +
+
+

Diagnoses

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

Procedures

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

Dermatology service revenue

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

Dermatology overview

+

History, skin exam, investigations, and care plan for this episode.

+
+ +
+ +
+
+

Complaint

+

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

+

Urgency {{ $exam['urgency'] ?? '—' }}

+
+
+

Lesion notes

+

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

+

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

+
+
+

Diagnosis / plan

+

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

+

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

+
+
+ +
+
+
History
+
{{ $hist['history'] ?? '—' }}
+
+
+
Plan
+
{{ $plan['plan'] ?? '—' }}
+
+
+
Queue
+
+ {{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }} + @if ($workspaceVisit->appointment?->queue_ticket_status) + ({{ $workspaceVisit->appointment->queue_ticket_status }}) + @endif +
+
+
+
Checked in
+
{{ $workspaceVisit->checked_in_at?->format('d M Y H:i') ?? '—' }}
+
+
+ + @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

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

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

Procedure / treatment

+

Completed procedures can close the dermatology 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 procedure recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/fertility/print.blade.php b/resources/views/care/specialty/fertility/print.blade.php new file mode 100644 index 0000000..41d92e2 --- /dev/null +++ b/resources/views/care/specialty/fertility/print.blade.php @@ -0,0 +1,90 @@ + + + + + Fertility summary · {{ $patient->fullName() }} + + + +

Back

+ +

{{ $patient->fullName() }}

+

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

+ +

History

+ @if ($history) +
+
HPI
{{ $history->payload['history'] ?? '—' }}
+
Trying (mo)
{{ $history->payload['trying_months'] ?? '—' }}
+
Obstetric
{{ $history->payload['obstetric_history'] ?? '—' }}
+
Partner
{{ $history->payload['partner_notes'] ?? '—' }}
+
+ @else +

No history recorded.

+ @endif + +

Exam

+ @if ($exam) +
+
Complaint
{{ $exam->payload['chief_complaint'] ?? '—' }}
+
Priority
{{ $exam->payload['priority'] ?? '—' }}
+
Cycle day
{{ $exam->payload['cycle_day'] ?? '—' }}
+
BMI
{{ $exam->payload['bmi'] ?? '—' }}
+
Exam
{{ $exam->payload['exam_findings'] ?? '—' }}
+
+ @else +

No exam recorded.

+ @endif + +

Investigations

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

No investigation recorded.

+ @endif + +

Diagnosis & plan

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

No plan recorded.

+ @endif + +

Cycle / procedure note

+ @if ($cycle) +
+
Cycle type
{{ $cycle->payload['cycle_type'] ?? '—' }}
+
Outcome
{{ $cycle->payload['outcome'] ?? '—' }}
+
Notes
{{ $cycle->payload['notes'] ?? '—' }}
+
Next steps
{{ $cycle->payload['next_steps'] ?? '—' }}
+
+ @else +

No cycle note recorded.

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

Fertility reports

+

Arrivals, priority open cases, diagnoses, cycle notes, length of stay, and fertility revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

Priority open

+

{{ number_format($report['priority_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 care plans in range.
  • + @endforelse +
+
+ +
+

Cycle notes

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

Fertility service revenue

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

Cycle / procedure note

+

Clinic-level cycle notes can close the fertility visit (not full IVF lab LIS).

+
+ 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 cycle note recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/fertility/workspace-overview.blade.php b/resources/views/care/specialty/fertility/workspace-overview.blade.php new file mode 100644 index 0000000..514d195 --- /dev/null +++ b/resources/views/care/specialty/fertility/workspace-overview.blade.php @@ -0,0 +1,71 @@ +@php + $hist = $fertilityHistory?->payload ?? []; + $exam = $fertilityExam?->payload ?? []; + $plan = $fertilityPlan?->payload ?? []; +@endphp + +
+
+
+

Fertility overview

+

History, exam, investigations, and fertility plan for this episode.

+
+ +
+ +
+
+

Complaint

+

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

+

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

+
+
+

Workup

+

Trying {{ $hist['trying_months'] ?? '—' }} mo

+

Cycle day {{ $exam['cycle_day'] ?? '—' }}

+
+
+

Diagnosis / plan

+

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

+

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

+
+
+ +
+
+
History
+
{{ $hist['history'] ?? '—' }}
+
+
+
Plan
+
{{ $plan['plan'] ?? '—' }}
+
+
+
Queue
+
+ {{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }} + @if ($workspaceVisit->appointment?->queue_ticket_status) + ({{ $workspaceVisit->appointment->queue_ticket_status }}) + @endif +
+
+
+
Checked in
+
{{ $workspaceVisit->checked_in_at?->format('d M Y H:i') ?? '—' }}
+
+
+ + @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

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

+ @endforeach +
+ @endif +
diff --git a/resources/views/care/specialty/partials/actions-menu.blade.php b/resources/views/care/specialty/partials/actions-menu.blade.php index 6a6c55d..c5e4e48 100644 --- a/resources/views/care/specialty/partials/actions-menu.blade.php +++ b/resources/views/care/specialty/partials/actions-menu.blade.php @@ -53,6 +53,9 @@ 'pediatrics' => 'check_in', 'orthopedics' => 'check_in', 'ent' => 'check_in', + 'dermatology' => 'check_in', + 'podiatry' => 'check_in', + 'fertility' => 'check_in', default => collect($stages ?? [])->pluck('code')->first(), }; $defaultStartLabel = match ($moduleKey) { @@ -68,6 +71,9 @@ 'pediatrics' => 'Start check-in', 'orthopedics' => 'Start check-in', 'ent' => 'Start check-in', + 'dermatology' => 'Start check-in', + 'podiatry' => 'Start check-in', + 'fertility' => 'Start check-in', default => 'Start', }; $currentStage = $workspaceVisit?->specialty_stage; diff --git a/resources/views/care/specialty/podiatry/print.blade.php b/resources/views/care/specialty/podiatry/print.blade.php new file mode 100644 index 0000000..66dce16 --- /dev/null +++ b/resources/views/care/specialty/podiatry/print.blade.php @@ -0,0 +1,90 @@ + + + + + Podiatry summary · {{ $patient->fullName() }} + + + +

Back

+ +

{{ $patient->fullName() }}

+

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

+ +

History

+ @if ($history) +
+
HPI
{{ $history->payload['history'] ?? '—' }}
+
Diabetes
{{ $history->payload['diabetes_history'] ?? '—' }}
+
Medications
{{ $history->payload['medications'] ?? '—' }}
+
Allergies
{{ $history->payload['allergies'] ?? '—' }}
+
+ @else +

No history recorded.

+ @endif + +

Foot exam

+ @if ($exam) +
+
Complaint
{{ $exam->payload['chief_complaint'] ?? '—' }}
+
Side
{{ $exam->payload['side'] ?? '—' }}
+
Diabetic risk
{{ $exam->payload['diabetes'] ?? '—' }}
+
Pulses
{{ $exam->payload['pulses'] ?? '—' }}
+
Sensation
{{ $exam->payload['sensation'] ?? '—' }}
+
Ulcer
{{ $exam->payload['ulcer'] ?? '—' }}
+
+ @else +

No exam recorded.

+ @endif + +

Investigations

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

No investigation recorded.

+ @endif + +

Diagnosis & plan

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

No plan recorded.

+ @endif + +

Procedure

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

No procedure recorded.

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

Podiatry reports

+

Arrivals, high-risk diabetic foot open cases, diagnoses, procedures, length of stay, and podiatry revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

High-risk open

+

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

+
+
+

Avg LOS (range)

+

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

+
+
+ +
+
+

Diagnoses

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

Procedures

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

Podiatry service revenue

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

Podiatry overview

+

History, foot exam, investigations, and care plan for this episode.

+
+ +
+ +
+
+

Complaint

+

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

+

{{ $exam['side'] ?? '—' }} · Risk {{ $exam['diabetes'] ?? '—' }}

+
+
+

Foot exam

+

Pulses {{ $exam['pulses'] ?? '—' }}

+

{{ \Illuminate\Support\Str::limit($exam['ulcer'] ?? ($exam['skin_nails'] ?? '—'), 50) }}

+
+
+

Diagnosis / plan

+

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

+

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

+
+
+ +
+
+
History
+
{{ $hist['history'] ?? '—' }}
+
+
+
Plan
+
{{ $plan['plan'] ?? '—' }}
+
+
+
Queue
+
+ {{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }} + @if ($workspaceVisit->appointment?->queue_ticket_status) + ({{ $workspaceVisit->appointment->queue_ticket_status }}) + @endif +
+
+
+
Checked in
+
{{ $workspaceVisit->checked_in_at?->format('d M Y H:i') ?? '—' }}
+
+
+ + @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

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

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

Procedure

+

Completed procedures can close the podiatry 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 procedure recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index 42a23fc..6b38aba 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -91,6 +91,12 @@ @include('care.specialty.pathology.workspace-'.$activeTab) @elseif ($moduleKey === 'infusion' && in_array($activeTab, ['overview', 'monitoring'], true)) @include('care.specialty.infusion.workspace-'.$activeTab) + @elseif ($moduleKey === 'dermatology' && in_array($activeTab, ['overview', 'procedure'], true)) + @include('care.specialty.dermatology.workspace-'.$activeTab) + @elseif ($moduleKey === 'podiatry' && in_array($activeTab, ['overview', 'procedure'], true)) + @include('care.specialty.podiatry.workspace-'.$activeTab) + @elseif ($moduleKey === 'fertility' && in_array($activeTab, ['overview', 'cycle'], true)) + @include('care.specialty.fertility.workspace-'.$activeTab) @elseif ($activeTab === 'billing')

diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index 1098e67..3a4be60 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -79,6 +79,15 @@ @if ($moduleKey === 'infusion') Reports @endif + @if ($moduleKey === 'dermatology') + Reports + @endif + @if ($moduleKey === 'podiatry') + Reports + @endif + @if ($moduleKey === 'fertility') + Reports + @endif History @if ($canManageClinical ?? $canManageSpecialty ?? true) Billing diff --git a/routes/web.php b/routes/web.php index dd98bb7..d5adb3a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -56,6 +56,9 @@ use App\Http\Controllers\Care\SurgeryWorkspaceController; use App\Http\Controllers\Care\VaccinationWorkspaceController; use App\Http\Controllers\Care\PathologyWorkspaceController; use App\Http\Controllers\Care\InfusionWorkspaceController; +use App\Http\Controllers\Care\DermatologyWorkspaceController; +use App\Http\Controllers\Care\PodiatryWorkspaceController; +use App\Http\Controllers\Care\FertilityWorkspaceController; use App\Http\Controllers\Care\SpecialtyModuleController; use App\Http\Controllers\Care\VisitWorkflowController; use App\Http\Controllers\NotificationController; @@ -153,6 +156,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/specialty/vaccination/reports', [VaccinationWorkspaceController::class, 'reports'])->name('care.specialty.vaccination.reports'); Route::get('/specialty/pathology/reports', [PathologyWorkspaceController::class, 'reports'])->name('care.specialty.pathology.reports'); Route::get('/specialty/infusion/reports', [InfusionWorkspaceController::class, 'reports'])->name('care.specialty.infusion.reports'); + Route::get('/specialty/dermatology/reports', [DermatologyWorkspaceController::class, 'reports'])->name('care.specialty.dermatology.reports'); + Route::get('/specialty/podiatry/reports', [PodiatryWorkspaceController::class, 'reports'])->name('care.specialty.podiatry.reports'); + Route::get('/specialty/fertility/reports', [FertilityWorkspaceController::class, 'reports'])->name('care.specialty.fertility.reports'); Route::get('/specialty/{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'); @@ -231,6 +237,15 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/specialty/infusion/workspace/{visit}/stage', [InfusionWorkspaceController::class, 'setStage'])->name('care.specialty.infusion.stage'); Route::post('/specialty/infusion/workspace/{visit}/monitoring', [InfusionWorkspaceController::class, 'saveMonitoring'])->name('care.specialty.infusion.monitoring'); Route::get('/specialty/infusion/workspace/{visit}/print', [InfusionWorkspaceController::class, 'printSummary'])->name('care.specialty.infusion.print'); + Route::post('/specialty/dermatology/workspace/{visit}/stage', [DermatologyWorkspaceController::class, 'setStage'])->name('care.specialty.dermatology.stage'); + Route::post('/specialty/dermatology/workspace/{visit}/procedure', [DermatologyWorkspaceController::class, 'saveProcedure'])->name('care.specialty.dermatology.procedure'); + Route::get('/specialty/dermatology/workspace/{visit}/print', [DermatologyWorkspaceController::class, 'printSummary'])->name('care.specialty.dermatology.print'); + Route::post('/specialty/podiatry/workspace/{visit}/stage', [PodiatryWorkspaceController::class, 'setStage'])->name('care.specialty.podiatry.stage'); + Route::post('/specialty/podiatry/workspace/{visit}/procedure', [PodiatryWorkspaceController::class, 'saveProcedure'])->name('care.specialty.podiatry.procedure'); + Route::get('/specialty/podiatry/workspace/{visit}/print', [PodiatryWorkspaceController::class, 'printSummary'])->name('care.specialty.podiatry.print'); + Route::post('/specialty/fertility/workspace/{visit}/stage', [FertilityWorkspaceController::class, 'setStage'])->name('care.specialty.fertility.stage'); + Route::post('/specialty/fertility/workspace/{visit}/cycle', [FertilityWorkspaceController::class, 'saveCycle'])->name('care.specialty.fertility.cycle'); + Route::get('/specialty/fertility/workspace/{visit}/print', [FertilityWorkspaceController::class, 'printSummary'])->name('care.specialty.fertility.print'); Route::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/CareDermatologySuiteTest.php b/tests/Feature/CareDermatologySuiteTest.php new file mode 100644 index 0000000..d8feb28 --- /dev/null +++ b/tests/Feature/CareDermatologySuiteTest.php @@ -0,0 +1,239 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'der-owner', + 'name' => 'Owner', + 'email' => 'der-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Dermatology Clinic', + 'slug' => 'dermatology-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, + 'dermatology', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'dermatology') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-DER-SUITE', + 'first_name' => 'Ama', + 'last_name' => 'Mensah', + 'gender' => 'female', + 'date_of_birth' => '1988-05-12', + ]); + + $this->visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + 'specialty_stage' => 'check_in', + ]); + + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'department_id' => $department->id, + 'visit_id' => $this->visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_IN_CONSULTATION, + 'scheduled_at' => now(), + 'waiting_at' => now(), + 'checked_in_at' => now(), + 'started_at' => now(), + ]); + } + + public function test_overview_and_exam_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'dermatology', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Dermatology overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Skin exam') + ->assertSee('Start history') + ->assertSee('Dermatology reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'dermatology', + 'visit' => $this->visit, + 'tab' => 'exam', + ])) + ->assertOk() + ->assertSee('Chief complaint') + ->assertSee('Lesion morphology'); + } + + public function test_exam_sets_stage_and_urgency_alert(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'dermatology', + 'visit' => $this->visit, + ]), [ + 'tab' => 'exam', + 'payload' => [ + 'chief_complaint' => 'Changing mole', + 'urgency' => 'Suspected malignancy', + 'duration' => '3 months', + 'distribution' => 'Left forearm', + 'morphology' => 'Asymmetric pigmented lesion with irregular border', + 'itch_pain' => 'None', + 'dermoscopy' => 'Atypical network', + 'differential' => 'Melanoma vs atypical naevus', + ], + ]) + ->assertRedirect(); + + $this->assertSame('exam', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'skin_exam') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('der.urgency_high', $codes); + } + + public function test_stage_advance_and_procedure_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.dermatology.stage', $this->visit), [ + 'stage' => 'procedure', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'dermatology', + 'visit' => $this->visit, + 'tab' => 'procedure', + ])); + + $this->assertSame('procedure', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.dermatology.procedure', $this->visit), [ + 'tab' => 'procedure', + 'payload' => [ + 'procedure' => 'Excision biopsy', + 'indication' => 'Suspected melanoma', + 'outcome' => 'Completed uneventfully', + 'post_procedure_plan' => 'Wound care; await histology', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'dermatology', + 'visit' => $this->visit, + 'tab' => 'procedure', + ])); + + $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' => 'derm_procedure', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.dermatology.reports')) + ->assertOk() + ->assertSee('Dermatology reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.dermatology.print', $this->visit)) + ->assertOk() + ->assertSee('Dermatology summary') + ->assertSee($this->patient->fullName()); + } + + public function test_list_index_does_not_auto_open_patient(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.show', 'dermatology')) + ->assertOk() + ->assertDontSee('Dermatology overview'); + } +} diff --git a/tests/Feature/CareFertilitySuiteTest.php b/tests/Feature/CareFertilitySuiteTest.php new file mode 100644 index 0000000..b3453df --- /dev/null +++ b/tests/Feature/CareFertilitySuiteTest.php @@ -0,0 +1,239 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'fer-owner', + 'name' => 'Owner', + 'email' => 'fer-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Fertility Clinic', + 'slug' => 'fertility-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, + 'fertility', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'fertility') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-FER-SUITE', + 'first_name' => 'Abena', + 'last_name' => 'Owusu', + 'gender' => 'female', + 'date_of_birth' => '1992-11-18', + ]); + + $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_exam_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'fertility', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Fertility overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Cycle note') + ->assertSee('Start history') + ->assertSee('Fertility reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'fertility', + 'visit' => $this->visit, + 'tab' => 'exam', + ])) + ->assertOk() + ->assertSee('Chief complaint') + ->assertSee('Exam findings'); + } + + public function test_exam_sets_stage_and_priority_alert(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'fertility', + 'visit' => $this->visit, + ]), [ + 'tab' => 'exam', + 'payload' => [ + 'chief_complaint' => 'Primary infertility', + 'priority' => 'Time-sensitive', + 'partner_present' => '1', + 'cycle_day' => 3, + 'bmi' => 24, + 'exam_findings' => 'Pelvic exam unremarkable', + 'notes' => 'Discussed workup timeline', + ], + ]) + ->assertRedirect(); + + $this->assertSame('exam', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'fert_exam') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('fer.priority_high', $codes); + } + + public function test_stage_advance_and_cycle_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.fertility.stage', $this->visit), [ + 'stage' => 'cycle', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'fertility', + 'visit' => $this->visit, + 'tab' => 'cycle', + ])); + + $this->assertSame('cycle', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.fertility.cycle', $this->visit), [ + 'tab' => 'cycle', + 'payload' => [ + 'cycle_type' => 'IUI', + 'cycle_day' => 14, + 'outcome' => 'Completed uneventfully', + 'notes' => 'IUI performed; luteal support started', + 'next_steps' => 'Pregnancy test in 14 days', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'fertility', + 'visit' => $this->visit, + 'tab' => 'cycle', + ])); + + $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' => 'fert_cycle', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.fertility.reports')) + ->assertOk() + ->assertSee('Fertility reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.fertility.print', $this->visit)) + ->assertOk() + ->assertSee('Fertility summary') + ->assertSee($this->patient->fullName()); + } + + public function test_list_index_does_not_auto_open_patient(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.show', 'fertility')) + ->assertOk() + ->assertDontSee('Fertility overview'); + } +} diff --git a/tests/Feature/CarePodiatrySuiteTest.php b/tests/Feature/CarePodiatrySuiteTest.php new file mode 100644 index 0000000..e2d39cc --- /dev/null +++ b/tests/Feature/CarePodiatrySuiteTest.php @@ -0,0 +1,239 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'pod-owner', + 'name' => 'Owner', + 'email' => 'pod-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Podiatry Clinic', + 'slug' => 'podiatry-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, + 'podiatry', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'podiatry') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-POD-SUITE', + 'first_name' => 'Kofi', + 'last_name' => 'Asante', + 'gender' => 'male', + 'date_of_birth' => '1965-08-03', + ]); + + $this->visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + 'specialty_stage' => 'check_in', + ]); + + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'department_id' => $department->id, + 'visit_id' => $this->visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_IN_CONSULTATION, + 'scheduled_at' => now(), + 'waiting_at' => now(), + 'checked_in_at' => now(), + 'started_at' => now(), + ]); + } + + public function test_overview_and_exam_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'podiatry', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Podiatry overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Foot exam') + ->assertSee('Start history') + ->assertSee('Podiatry reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'podiatry', + 'visit' => $this->visit, + 'tab' => 'exam', + ])) + ->assertOk() + ->assertSee('Chief complaint') + ->assertSee('Diabetic foot risk'); + } + + public function test_exam_sets_stage_and_diabetes_alert(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'podiatry', + 'visit' => $this->visit, + ]), [ + 'tab' => 'exam', + 'payload' => [ + 'chief_complaint' => 'Plantar ulcer', + 'side' => 'Right', + 'diabetes' => 'Active ulcer', + 'pulses' => 'Dorsalis pedis weak', + 'sensation' => 'Reduced monofilament', + 'skin_nails' => 'Callus surrounding ulcer', + 'ulcer' => '2cm plantar ulcer, clean base', + 'offloading' => 'Total contact cast planned', + ], + ]) + ->assertRedirect(); + + $this->assertSame('exam', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'foot_exam') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('pod.diabetes_high', $codes); + } + + public function test_stage_advance_and_procedure_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.podiatry.stage', $this->visit), [ + 'stage' => 'procedure', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'podiatry', + 'visit' => $this->visit, + 'tab' => 'procedure', + ])); + + $this->assertSame('procedure', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.podiatry.procedure', $this->visit), [ + 'tab' => 'procedure', + 'payload' => [ + 'procedure' => 'Sharp debridement', + 'indication' => 'Diabetic foot ulcer', + 'outcome' => 'Completed uneventfully', + 'post_procedure_plan' => 'Dressing; offloading; review 1 week', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'podiatry', + 'visit' => $this->visit, + 'tab' => 'procedure', + ])); + + $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' => 'pod_procedure', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.podiatry.reports')) + ->assertOk() + ->assertSee('Podiatry reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.podiatry.print', $this->visit)) + ->assertOk() + ->assertSee('Podiatry summary') + ->assertSee($this->patient->fullName()); + } + + public function test_list_index_does_not_auto_open_patient(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.show', 'podiatry')) + ->assertOk() + ->assertDontSee('Podiatry overview'); + } +}