diff --git a/app/Http/Controllers/Care/EntWorkspaceController.php b/app/Http/Controllers/Care/EntWorkspaceController.php new file mode 100644 index 0000000..9b98a39 --- /dev/null +++ b/app/Http/Controllers/Care/EntWorkspaceController.php @@ -0,0 +1,215 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'ent'), 403); + } + + protected function assertEntManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'ent'), 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->assertEntManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'ent', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'ent', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('ent', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveProcedure( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + EntWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertEntManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('ent', 'ent_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('ent', 'ent_procedure'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'ent', + 'ent_procedure', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + EntWorkflowService::STAGE_PROCEDURE, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'ent', + EntWorkflowService::STAGE_PROCEDURE, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'ent', + EntWorkflowService::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' => 'ent', 'visit' => $visit, 'tab' => 'procedure']) + ->with('success', 'ENT procedure saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + EntAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertEntAccess($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.ent.reports', [ + 'moduleKey' => 'ent', + 'definition' => $modules->definition('ent'), + 'shellNav' => $shell->navItems('ent'), + '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->assertEntAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.ent.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'history' => $clinical->findForVisit($visit, 'ent', 'ent_history'), + 'exam' => $clinical->findForVisit($visit, 'ent', 'ent_exam'), + 'investigation' => $clinical->findForVisit($visit, 'ent', 'ent_investigation'), + 'plan' => $clinical->findForVisit($visit, 'ent', 'ent_plan'), + 'procedure' => $clinical->findForVisit($visit, 'ent', 'ent_procedure'), + ]); + } +} diff --git a/app/Http/Controllers/Care/OrthopedicsWorkspaceController.php b/app/Http/Controllers/Care/OrthopedicsWorkspaceController.php new file mode 100644 index 0000000..9d799e3 --- /dev/null +++ b/app/Http/Controllers/Care/OrthopedicsWorkspaceController.php @@ -0,0 +1,215 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'orthopedics'), 403); + } + + protected function assertOrthopedicsManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'orthopedics'), 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->assertOrthopedicsManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'orthopedics', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'orthopedics', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('orthopedics', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveProcedure( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + OrthopedicsWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertOrthopedicsManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('orthopedics', 'ortho_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('orthopedics', 'ortho_procedure'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'orthopedics', + 'ortho_procedure', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + OrthopedicsWorkflowService::STAGE_PROCEDURE, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'orthopedics', + OrthopedicsWorkflowService::STAGE_PROCEDURE, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'orthopedics', + OrthopedicsWorkflowService::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' => 'orthopedics', 'visit' => $visit, 'tab' => 'procedure']) + ->with('success', 'Procedure / cast saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + OrthopedicsAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertOrthopedicsAccess($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.orthopedics.reports', [ + 'moduleKey' => 'orthopedics', + 'definition' => $modules->definition('orthopedics'), + 'shellNav' => $shell->navItems('orthopedics'), + '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->assertOrthopedicsAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.orthopedics.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'history' => $clinical->findForVisit($visit, 'orthopedics', 'ortho_history'), + 'exam' => $clinical->findForVisit($visit, 'orthopedics', 'ortho_exam'), + 'imaging' => $clinical->findForVisit($visit, 'orthopedics', 'ortho_imaging'), + 'plan' => $clinical->findForVisit($visit, 'orthopedics', 'ortho_plan'), + 'procedure' => $clinical->findForVisit($visit, 'orthopedics', 'ortho_procedure'), + ]); + } +} diff --git a/app/Http/Controllers/Care/PediatricsWorkspaceController.php b/app/Http/Controllers/Care/PediatricsWorkspaceController.php new file mode 100644 index 0000000..f6eaef9 --- /dev/null +++ b/app/Http/Controllers/Care/PediatricsWorkspaceController.php @@ -0,0 +1,215 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'pediatrics'), 403); + } + + protected function assertPediatricsManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'pediatrics'), 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->assertPediatricsManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'pediatrics', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', [ + 'module' => 'pediatrics', + 'visit' => $visit, + 'tab' => $shell->workspaceTabForStage('pediatrics', $validated['stage']), + ]) + ->with('success', 'Visit stage updated.'); + } + + public function saveTreatment( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + PediatricsWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertPediatricsManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('pediatrics', 'ped_treatment') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'treatment']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('pediatrics', 'ped_treatment'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'pediatrics', + 'ped_treatment', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + PediatricsWorkflowService::STAGE_TREATMENT, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'pediatrics', + PediatricsWorkflowService::STAGE_TREATMENT, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'pediatrics', + PediatricsWorkflowService::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' => 'pediatrics', 'visit' => $visit, 'tab' => 'treatment']) + ->with('success', 'Treatment / immunization saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + PediatricsAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertPediatricsAccess($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.pediatrics.reports', [ + 'moduleKey' => 'pediatrics', + 'definition' => $modules->definition('pediatrics'), + 'shellNav' => $shell->navItems('pediatrics'), + '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->assertPediatricsAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.pediatrics.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'history' => $clinical->findForVisit($visit, 'pediatrics', 'ped_history'), + 'exam' => $clinical->findForVisit($visit, 'pediatrics', 'pediatric_exam'), + 'investigation' => $clinical->findForVisit($visit, 'pediatrics', 'ped_investigation'), + 'plan' => $clinical->findForVisit($visit, 'pediatrics', 'ped_plan'), + 'treatment' => $clinical->findForVisit($visit, 'pediatrics', 'ped_treatment'), + ]); + } +} diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 9f3faac..aac4efe 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -159,6 +159,9 @@ class SpecialtyModuleController extends Controller 'radiology' => 'protocol', 'cardiology' => 'history', 'psychiatry' => 'history', + 'pediatrics' => 'history', + 'orthopedics' => 'history', + 'ent' => 'history', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -236,6 +239,9 @@ class SpecialtyModuleController extends Controller 'radiology' => 'protocol', 'cardiology' => 'history', 'psychiatry' => 'history', + 'pediatrics' => 'history', + 'orthopedics' => 'history', + 'ent' => 'history', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -656,6 +662,150 @@ class SpecialtyModuleController extends Controller } } + if ($module === 'pediatrics' && in_array($recordType, ['ped_history', 'pediatric_exam', 'ped_investigation', 'ped_plan'], true)) { + $workflow = app(\App\Services\Care\Pediatrics\PediatricsWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'ped_history' => $workflow->stageFromHistory($record->payload ?? []), + 'pediatric_exam' => $workflow->stageFromExam($record->payload ?? []), + 'ped_investigation' => $workflow->stageFromInvestigation($record->payload ?? []), + 'ped_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, + 'treatment' => 5, + 'completed' => 6, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage( + $organization, + $visit, + 'pediatrics', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage( + $organization, + $visit, + 'pediatrics', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } + } + + if ($module === 'orthopedics' && in_array($recordType, ['ortho_history', 'ortho_exam', 'ortho_imaging', 'ortho_plan'], true)) { + $workflow = app(\App\Services\Care\Orthopedics\OrthopedicsWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'ortho_history' => $workflow->stageFromHistory($record->payload ?? []), + 'ortho_exam' => $workflow->stageFromExam($record->payload ?? []), + 'ortho_imaging' => $workflow->stageFromImaging($record->payload ?? []), + 'ortho_plan' => $workflow->stageFromPlan($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'waiting', null, '']; + $order = [ + 'check_in' => 0, + 'history' => 1, + 'exam' => 2, + 'imaging' => 3, + 'plan' => 4, + 'procedure' => 5, + 'completed' => 6, + ]; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage( + $organization, + $visit, + 'orthopedics', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage( + $organization, + $visit, + 'orthopedics', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } + } + + if ($module === 'ent' && in_array($recordType, ['ent_history', 'ent_exam', 'ent_investigation', 'ent_plan'], true)) { + $workflow = app(\App\Services\Care\Ent\EntWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'ent_history' => $workflow->stageFromHistory($record->payload ?? []), + 'ent_exam' => $workflow->stageFromExam($record->payload ?? []), + 'ent_investigation' => $workflow->stageFromInvestigation($record->payload ?? []), + 'ent_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, + 'ent', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current && ($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage( + $organization, + $visit, + 'ent', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } + } + return redirect() ->route('care.specialty.workspace', [ 'module' => $module, @@ -1091,6 +1241,27 @@ class SpecialtyModuleController extends Controller $psychiatryFollowup = null; $psychiatryStageCodes = []; $psychiatryStageFlow = []; + $pediatricsHistory = null; + $pediatricsExam = null; + $pediatricsInvestigation = null; + $pediatricsPlan = null; + $pediatricsTreatment = null; + $pediatricsStageCodes = []; + $pediatricsStageFlow = []; + $orthopedicsHistory = null; + $orthopedicsExam = null; + $orthopedicsImaging = null; + $orthopedicsPlan = null; + $orthopedicsProcedure = null; + $orthopedicsStageCodes = []; + $orthopedicsStageFlow = []; + $entHistory = null; + $entExam = null; + $entInvestigation = null; + $entPlan = null; + $entProcedure = null; + $entStageCodes = []; + $entStageFlow = []; $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); $clinical = app(SpecialtyClinicalRecordService::class); @@ -1255,6 +1426,36 @@ class SpecialtyModuleController extends Controller $psychiatryStageFlow = app(\App\Services\Care\Psychiatry\PsychiatryWorkflowService::class)->stageFlow(); } + if ($module === 'pediatrics') { + $pediatricsHistory = $clinical->findForVisit($workspaceVisit, 'pediatrics', 'ped_history'); + $pediatricsExam = $clinical->findForVisit($workspaceVisit, 'pediatrics', 'pediatric_exam'); + $pediatricsInvestigation = $clinical->findForVisit($workspaceVisit, 'pediatrics', 'ped_investigation'); + $pediatricsPlan = $clinical->findForVisit($workspaceVisit, 'pediatrics', 'ped_plan'); + $pediatricsTreatment = $clinical->findForVisit($workspaceVisit, 'pediatrics', 'ped_treatment'); + $pediatricsStageCodes = collect($shell->stages('pediatrics'))->pluck('code')->all(); + $pediatricsStageFlow = app(\App\Services\Care\Pediatrics\PediatricsWorkflowService::class)->stageFlow(); + } + + if ($module === 'orthopedics') { + $orthopedicsHistory = $clinical->findForVisit($workspaceVisit, 'orthopedics', 'ortho_history'); + $orthopedicsExam = $clinical->findForVisit($workspaceVisit, 'orthopedics', 'ortho_exam'); + $orthopedicsImaging = $clinical->findForVisit($workspaceVisit, 'orthopedics', 'ortho_imaging'); + $orthopedicsPlan = $clinical->findForVisit($workspaceVisit, 'orthopedics', 'ortho_plan'); + $orthopedicsProcedure = $clinical->findForVisit($workspaceVisit, 'orthopedics', 'ortho_procedure'); + $orthopedicsStageCodes = collect($shell->stages('orthopedics'))->pluck('code')->all(); + $orthopedicsStageFlow = app(\App\Services\Care\Orthopedics\OrthopedicsWorkflowService::class)->stageFlow(); + } + + if ($module === 'ent') { + $entHistory = $clinical->findForVisit($workspaceVisit, 'ent', 'ent_history'); + $entExam = $clinical->findForVisit($workspaceVisit, 'ent', 'ent_exam'); + $entInvestigation = $clinical->findForVisit($workspaceVisit, 'ent', 'ent_investigation'); + $entPlan = $clinical->findForVisit($workspaceVisit, 'ent', 'ent_plan'); + $entProcedure = $clinical->findForVisit($workspaceVisit, 'ent', 'ent_procedure'); + $entStageCodes = collect($shell->stages('ent'))->pluck('code')->all(); + $entStageFlow = app(\App\Services\Care\Ent\EntWorkflowService::class)->stageFlow(); + } + if ($patientHeader !== null) { $patientHeader['clinical_alerts'] = $clinicalAlerts; } @@ -1382,6 +1583,27 @@ class SpecialtyModuleController extends Controller 'psychiatryFollowup' => $psychiatryFollowup, 'psychiatryStageCodes' => $psychiatryStageCodes, 'psychiatryStageFlow' => $psychiatryStageFlow, + 'pediatricsHistory' => $pediatricsHistory, + 'pediatricsExam' => $pediatricsExam, + 'pediatricsInvestigation' => $pediatricsInvestigation, + 'pediatricsPlan' => $pediatricsPlan, + 'pediatricsTreatment' => $pediatricsTreatment, + 'pediatricsStageCodes' => $pediatricsStageCodes, + 'pediatricsStageFlow' => $pediatricsStageFlow, + 'orthopedicsHistory' => $orthopedicsHistory, + 'orthopedicsExam' => $orthopedicsExam, + 'orthopedicsImaging' => $orthopedicsImaging, + 'orthopedicsPlan' => $orthopedicsPlan, + 'orthopedicsProcedure' => $orthopedicsProcedure, + 'orthopedicsStageCodes' => $orthopedicsStageCodes, + 'orthopedicsStageFlow' => $orthopedicsStageFlow, + 'entHistory' => $entHistory, + 'entExam' => $entExam, + 'entInvestigation' => $entInvestigation, + 'entPlan' => $entPlan, + 'entProcedure' => $entProcedure, + 'entStageCodes' => $entStageCodes, + 'entStageFlow' => $entStageFlow, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'branchLabel' => $branchLabel, diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index a553c57..a00ff5e 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -1020,6 +1020,9 @@ class DemoTenantSeeder $this->seedRadiologyClinicalDemo($organization, $ownerRef); $this->seedCardiologyClinicalDemo($organization, $ownerRef); $this->seedPsychiatryClinicalDemo($organization, $ownerRef); + $this->seedPediatricsClinicalDemo($organization, $ownerRef); + $this->seedOrthopedicsClinicalDemo($organization, $ownerRef); + $this->seedEntClinicalDemo($organization, $ownerRef); return $count; } @@ -1856,6 +1859,369 @@ class DemoTenantSeeder } } + private function seedPediatricsClinicalDemo(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', 'pediatrics') + ->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, + 'pediatrics', + 'ped_history', + [ + 'history' => $highRisk + ? 'Fever and poor feeding for 3 days; reduced urine output' + : 'Mild cough for 1 week; eating well', + 'birth_history' => $highRisk ? 'Term; birth weight 2.8 kg' : 'Term; uneventful', + 'past_medical' => $highRisk ? 'Prior admission for pneumonia' : 'None', + 'medications' => 'None', + 'allergies' => 'NKDA', + 'feeding' => $highRisk ? 'Breast + formula; intake reduced' : 'Age-appropriate diet', + 'social' => 'Lives with parents', + ], + $ownerRef, + $ownerRef, + 'history', + ); + + $clinical->upsert( + $organization, + $visit, + 'pediatrics', + 'pediatric_exam', + [ + 'chief_complaint' => $highRisk ? 'Fever / possible sepsis' : 'Cough review', + 'age_months' => $highRisk ? 18 : 48, + 'weight_kg' => $highRisk ? 8.2 : 16.5, + 'height_cm' => $highRisk ? 76 : 102, + 'head_circumference_cm' => $highRisk ? 45 : null, + 'temperature_c' => $highRisk ? 39.2 : 36.8, + 'growth_concern' => $highRisk ? 'Underweight' : 'None', + 'developmental' => $highRisk ? 'Walks; few words' : 'Age-appropriate', + 'immunization_status' => $highRisk ? 'Up to date except measles' : 'Up to date', + 'examination' => $highRisk + ? 'Irritable; mild dehydration; clear chest; no rash' + : 'Well; mild rhinitis; chest clear', + ], + $ownerRef, + $ownerRef, + 'exam', + ); + + if ($highRisk) { + $clinical->upsert( + $organization, + $visit, + 'pediatrics', + 'ped_plan', + [ + 'diagnosis' => 'Febrile illness — rule out serious bacterial infection', + 'plan' => 'Labs; oral fluids; antipyretic; review same day if worse', + 'medications' => 'Paracetamol PRN', + 'treatment_planned' => true, + 'follow_up' => '24 hours or sooner if red flags', + 'advice' => 'Hydration; fever advice; return if lethargy or poor feeding', + ], + $ownerRef, + $ownerRef, + 'plan', + ); + } + + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']); + } + + $index++; + } + } + + private function seedOrthopedicsClinicalDemo(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', 'orthopedics') + ->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, + 'orthopedics', + 'ortho_history', + [ + 'history' => $highRisk + ? 'Fall from height; right wrist pain and deformity' + : 'Left knee pain after football twist', + 'mechanism' => $highRisk ? 'FOOSH from ladder' : 'Twisting injury', + 'past_ortho' => $highRisk ? 'Prior ankle sprain' : 'None', + 'medications' => 'None', + 'allergies' => 'NKDA', + 'comorbidities' => $highRisk ? 'Diabetes' : 'None', + ], + $ownerRef, + $ownerRef, + 'history', + ); + + $clinical->upsert( + $organization, + $visit, + 'orthopedics', + 'ortho_exam', + [ + 'chief_complaint' => $highRisk ? 'Wrist deformity after fall' : 'Knee pain', + 'side' => $highRisk ? 'Right' : 'Left', + 'region' => $highRisk ? 'Distal radius' : 'Knee', + 'rom' => $highRisk ? 'Limited wrist flexion/extension' : 'Full ROM; pain on twist', + 'neurovascular' => $highRisk ? 'Sensory deficit' : 'Intact', + 'swelling' => $highRisk ? 'Dinner-fork deformity' : 'Mild effusion', + 'exam_findings' => $highRisk ? 'Closed injury; soft compartments' : 'McMurray negative; stable ligaments', + 'fracture_classification' => $highRisk ? 'Colles-type distal radius' : 'Soft tissue strain', + ], + $ownerRef, + $ownerRef, + 'exam', + ); + + if ($highRisk) { + $clinical->upsert( + $organization, + $visit, + 'orthopedics', + 'ortho_imaging', + [ + 'modality' => 'X-ray', + 'region' => 'Right wrist', + 'findings' => 'Displaced distal radius fracture; ulnar styloid intact', + 'impression' => 'Extra-articular distal radius fracture', + 'notes' => 'AP and lateral views', + ], + $ownerRef, + $ownerRef, + 'imaging', + ); + + $clinical->upsert( + $organization, + $visit, + 'orthopedics', + 'ortho_plan', + [ + 'diagnosis' => 'Displaced distal radius fracture', + 'plan' => 'Closed reduction and cast; elevate; analgesia', + 'weight_bearing' => 'N/A — upper limb', + 'procedure_planned' => true, + 'follow_up' => '1 week with cast check / x-ray', + 'advice' => 'Elevation; watch for numbness or colour change', + ], + $ownerRef, + $ownerRef, + 'plan', + ); + } + + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']); + } + + $index++; + } + } + + private function seedEntClinicalDemo(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', 'ent') + ->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, + 'ent', + 'ent_history', + [ + 'history' => $highRisk + ? 'Progressive sore throat and muffled voice; difficulty swallowing' + : 'Recurrent ear discharge for 2 months', + 'ear_symptoms' => $highRisk ? 'None' : 'Left otorrhoea; mild hearing loss', + 'nose_symptoms' => 'None', + 'throat_symptoms' => $highRisk ? 'Severe odynophagia; drooling' : 'None', + 'past_ent' => $highRisk ? 'Prior tonsillitis' : 'Recurrent otitis media', + 'medications' => $highRisk ? 'None recent' : 'Topical drops PRN', + 'allergies' => 'NKDA', + ], + $ownerRef, + $ownerRef, + 'history', + ); + + $clinical->upsert( + $organization, + $visit, + 'ent', + 'ent_exam', + [ + 'chief_complaint' => $highRisk ? 'Sore throat / possible peritonsillar abscess' : 'Chronic ear discharge', + 'ear_findings' => $highRisk ? 'Normal TMs' : 'Left canal moist; TM perforated', + 'nose_findings' => 'Normal', + 'throat_findings' => $highRisk + ? 'Trismus; unilateral tonsillar bulge; uvula deviation' + : 'Oropharynx normal', + 'hearing' => $highRisk ? 'Grossly normal' : 'Reduced left whispered voice', + 'neck' => $highRisk ? 'Tender cervical nodes' : 'No lymphadenopathy', + 'airway_concern' => $highRisk, + 'exam_summary' => $highRisk + ? 'Findings consistent with peritonsillar abscess — airway vigilance' + : 'Chronic otitis media with perforation', + ], + $ownerRef, + $ownerRef, + 'exam', + ); + + if ($highRisk) { + $clinical->upsert( + $organization, + $visit, + 'ent', + 'ent_plan', + [ + 'diagnosis' => 'Suspected peritonsillar abscess', + 'plan' => 'IV antibiotics; needle aspiration / I&D; airway monitoring', + 'medications' => 'IV antibiotics per protocol; analgesia', + 'procedure_planned' => true, + 'follow_up' => 'Inpatient review post-drainage', + 'advice' => 'NPO until airway safe; urgent escalation if stridor', + ], + $ownerRef, + $ownerRef, + 'plan', + ); + } + + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $highRisk ? 'plan' : 'exam']); + } + + $index++; + } + } + private function seedDentistryClinicalDemo(Organization $organization, string $ownerRef): void { // Specialty departments are provisioned without organization_id; scope via branches. diff --git a/app/Services/Care/Ent/EntAnalyticsService.php b/app/Services/Care/Ent/EntAnalyticsService.php new file mode 100644 index 0000000..d94cfdf --- /dev/null +++ b/app/Services/Care/Ent/EntAnalyticsService.php @@ -0,0 +1,164 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'ent', $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'); + + $airwayOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'ent') + ->where('record_type', 'ent_exam') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(fn (SpecialtyClinicalRecord $r) => ! empty($r->payload['airway_concern'])) + ->count(); + + $planRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'ent') + ->where('record_type', 'ent_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', 'ent') + ->where('record_type', 'ent_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, 'ent')) + ->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, + 'airway_open' => $airwayOpen, + '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/Ent/EntWorkflowService.php b/app/Services/Care/Ent/EntWorkflowService.php new file mode 100644 index 0000000..e185602 --- /dev/null +++ b/app/Services/Care/Ent/EntWorkflowService.php @@ -0,0 +1,107 @@ + $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['ear_findings']) + || ! empty($payload['nose_findings']) + || ! empty($payload['throat_findings'])) { + return self::STAGE_EXAM; + } + + return self::STAGE_HISTORY; + } + + /** + * @param array $payload + */ + public function stageFromInvestigation(array $payload): string + { + $findings = trim((string) ($payload['findings'] ?? '')); + if ($findings !== '') { + return self::STAGE_INVESTIGATION; + } + + return self::STAGE_EXAM; + } + + /** + * @param array $payload + */ + public function stageFromPlan(array $payload): string + { + if (! empty($payload['procedure_planned'])) { + return self::STAGE_PROCEDURE; + } + + $plan = trim((string) ($payload['plan'] ?? '')); + if ($plan !== '') { + return self::STAGE_PLAN; + } + + return self::STAGE_INVESTIGATION; + } + + /** + * @param array $payload + */ + public function 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_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/Orthopedics/OrthopedicsAnalyticsService.php b/app/Services/Care/Orthopedics/OrthopedicsAnalyticsService.php new file mode 100644 index 0000000..7fc0306 --- /dev/null +++ b/app/Services/Care/Orthopedics/OrthopedicsAnalyticsService.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, 'orthopedics', $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'); + + $nvCompromiseOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'orthopedics') + ->where('record_type', 'ortho_exam') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(function (SpecialtyClinicalRecord $r) { + $nv = strtolower((string) ($r->payload['neurovascular'] ?? '')); + + return str_contains($nv, 'deficit') || str_contains($nv, 'compromise'); + }) + ->count(); + + $planRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'orthopedics') + ->where('record_type', 'ortho_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', 'orthopedics') + ->where('record_type', 'ortho_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, 'orthopedics')) + ->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, + 'nv_compromise_open' => $nvCompromiseOpen, + '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/Orthopedics/OrthopedicsWorkflowService.php b/app/Services/Care/Orthopedics/OrthopedicsWorkflowService.php new file mode 100644 index 0000000..a669120 --- /dev/null +++ b/app/Services/Care/Orthopedics/OrthopedicsWorkflowService.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['region']) || ! empty($payload['chief_complaint'])) { + return self::STAGE_EXAM; + } + + return self::STAGE_HISTORY; + } + + /** + * @param array $payload + */ + public function stageFromImaging(array $payload): string + { + $findings = trim((string) ($payload['findings'] ?? '')); + if ($findings !== '') { + return self::STAGE_IMAGING; + } + + 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_IMAGING; + } + + /** + * @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_IMAGING, 'label' => 'Move to imaging'], + self::STAGE_IMAGING => ['next' => self::STAGE_PLAN, 'label' => 'Move to diagnosis & plan'], + self::STAGE_PLAN => ['next' => self::STAGE_PROCEDURE, 'label' => 'Move to procedure / cast'], + 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/Pediatrics/PediatricsAnalyticsService.php b/app/Services/Care/Pediatrics/PediatricsAnalyticsService.php new file mode 100644 index 0000000..3d39cf5 --- /dev/null +++ b/app/Services/Care/Pediatrics/PediatricsAnalyticsService.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, 'pediatrics', $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'); + + $growthConcernOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'pediatrics') + ->where('record_type', 'pediatric_exam') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(function (SpecialtyClinicalRecord $r) { + $concern = (string) ($r->payload['growth_concern'] ?? ''); + + return $concern !== '' && $concern !== 'None'; + }) + ->count(); + + $planRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'pediatrics') + ->where('record_type', 'ped_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(); + + $treatmentRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'pediatrics') + ->where('record_type', 'ped_treatment') + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('recorded_at', [$rangeFrom, $rangeTo]) + ->get(); + + $treatmentBreakdown = $treatmentRecords + ->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['treatment'] ?? 'Unknown')) + ->map(fn (Collection $rows, string $treatment) => [ + 'treatment' => $treatment, + '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, 'pediatrics')) + ->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, + 'growth_concern_open' => $growthConcernOpen, + 'diagnosis_breakdown' => $diagnosisBreakdown, + 'treatment_breakdown' => $treatmentBreakdown, + 'avg_los_minutes' => $avgLos, + 'revenue_by_service' => $revenueByService, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), + ]; + } +} diff --git a/app/Services/Care/Pediatrics/PediatricsWorkflowService.php b/app/Services/Care/Pediatrics/PediatricsWorkflowService.php new file mode 100644 index 0000000..53cc426 --- /dev/null +++ b/app/Services/Care/Pediatrics/PediatricsWorkflowService.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['examination']) || ! 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['treatment_planned'])) { + return self::STAGE_TREATMENT; + } + + $plan = trim((string) ($payload['plan'] ?? '')); + if ($plan !== '') { + return self::STAGE_PLAN; + } + + return self::STAGE_INVESTIGATION; + } + + /** + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return str_contains($outcome, 'completed'); + } + + /** + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_CHECK_IN => ['next' => self::STAGE_HISTORY, 'label' => 'Start history'], + self::STAGE_HISTORY => ['next' => self::STAGE_EXAM, 'label' => 'Move to exam / growth'], + 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_TREATMENT, 'label' => 'Move to treatment'], + self::STAGE_TREATMENT => ['next' => self::STAGE_COMPLETED, 'label' => 'Complete visit'], + self::STAGE_COMPLETED => ['next' => self::STAGE_COMPLETED, 'label' => 'Completed'], + ]; + } +} diff --git a/app/Services/Care/SpecialtyClinicalRecordService.php b/app/Services/Care/SpecialtyClinicalRecordService.php index 6c0652c..3913c69 100644 --- a/app/Services/Care/SpecialtyClinicalRecordService.php +++ b/app/Services/Care/SpecialtyClinicalRecordService.php @@ -419,6 +419,58 @@ class SpecialtyClinicalRecordService } } + if ($moduleKey === 'pediatrics' && $recordType === 'pediatric_exam') { + $concern = (string) ($payload['growth_concern'] ?? ''); + if ($concern !== '' && $concern !== 'None') { + $alerts[] = [ + 'code' => 'ped.growth_concern', + 'severity' => in_array($concern, ['Wasting', 'Underweight'], true) ? 'critical' : 'warning', + 'message' => 'Growth concern: '.$concern.'.', + ]; + } + $temp = (float) ($payload['temperature_c'] ?? 0); + if ($temp >= 39.0) { + $alerts[] = [ + 'code' => 'ped.fever_high', + 'severity' => 'critical', + 'message' => 'High fever recorded ('.$temp.'°C).', + ]; + } elseif ($temp >= 38.0) { + $alerts[] = [ + 'code' => 'ped.fever', + 'severity' => 'warning', + 'message' => 'Fever recorded ('.$temp.'°C).', + ]; + } + } + + if ($moduleKey === 'orthopedics' && $recordType === 'ortho_exam') { + $nv = strtolower((string) ($payload['neurovascular'] ?? '')); + if (str_contains($nv, 'vascular compromise')) { + $alerts[] = [ + 'code' => 'ort.nv_vascular', + 'severity' => 'critical', + 'message' => 'Vascular compromise on ortho exam.', + ]; + } elseif (str_contains($nv, 'deficit')) { + $alerts[] = [ + 'code' => 'ort.nv_deficit', + 'severity' => 'warning', + 'message' => 'Neurovascular deficit recorded.', + ]; + } + } + + if ($moduleKey === 'ent' && $recordType === 'ent_exam') { + if (! empty($payload['airway_concern'])) { + $alerts[] = [ + 'code' => 'ent.airway_concern', + 'severity' => 'critical', + 'message' => 'Airway concern flagged on ENT exam.', + ]; + } + } + if ($moduleKey === 'psychiatry' && $recordType === 'risk_assessment') { $level = strtolower((string) ($payload['overall_risk'] ?? '')); if (str_contains($level, 'imminent')) { diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php index 73abd87..b77c81a 100644 --- a/app/Services/Care/SpecialtyShellService.php +++ b/app/Services/Care/SpecialtyShellService.php @@ -17,6 +17,9 @@ use App\Services\Care\Maternity\MaternityWorkflowService; use App\Services\Care\Radiology\RadiologyWorkflowService; use App\Services\Care\Cardiology\CardiologyWorkflowService; use App\Services\Care\Psychiatry\PsychiatryWorkflowService; +use App\Services\Care\Pediatrics\PediatricsWorkflowService; +use App\Services\Care\Orthopedics\OrthopedicsWorkflowService; +use App\Services\Care\Ent\EntWorkflowService; use Illuminate\Support\Collection; /** @@ -66,6 +69,9 @@ class SpecialtyShellService 'radiology' => 'care.specialty.radiology.stage', 'cardiology' => 'care.specialty.cardiology.stage', 'psychiatry' => 'care.specialty.psychiatry.stage', + 'pediatrics' => 'care.specialty.pediatrics.stage', + 'orthopedics' => 'care.specialty.orthopedics.stage', + 'ent' => 'care.specialty.ent.stage', default => null, }; } @@ -86,6 +92,9 @@ class SpecialtyShellService 'radiology' => app(RadiologyWorkflowService::class)->stageFlow(), 'cardiology' => app(CardiologyWorkflowService::class)->stageFlow(), 'psychiatry' => app(PsychiatryWorkflowService::class)->stageFlow(), + 'pediatrics' => app(PediatricsWorkflowService::class)->stageFlow(), + 'orthopedics' => app(OrthopedicsWorkflowService::class)->stageFlow(), + 'ent' => app(EntWorkflowService::class)->stageFlow(), 'dentistry' => [ 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], @@ -385,7 +394,7 @@ class SpecialtyShellService ) { $code = (string) ($stage['code'] ?? ''); - if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry'], true) && $visitIds->isNotEmpty()) { + if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology', 'physiotherapy', 'maternity', 'radiology', 'cardiology', 'psychiatry', 'pediatrics', 'orthopedics', 'ent'], true) && $visitIds->isNotEmpty()) { $count = Visit::owned($ownerRef) ->where('organization_id', $organization->id) ->whereIn('id', $visitIds) @@ -421,6 +430,9 @@ class SpecialtyShellService 'radiology' => $clinical->countOpenByType($organization, 'radiology', 'imaging_request', $branchScope), 'cardiology' => $clinical->countOpenByType($organization, 'cardiology', 'cardiac_exam', $branchScope), 'psychiatry' => $clinical->countOpenByType($organization, 'psychiatry', 'mental_status', $branchScope), + 'pediatrics' => $clinical->countOpenByType($organization, 'pediatrics', 'pediatric_exam', $branchScope), + 'orthopedics' => $clinical->countOpenByType($organization, 'orthopedics', 'ortho_exam', $branchScope), + 'ent' => $clinical->countOpenByType($organization, 'ent', 'ent_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 083b736..9274942 100644 --- a/app/Services/Care/SpecialtyVisitStageService.php +++ b/app/Services/Care/SpecialtyVisitStageService.php @@ -144,6 +144,30 @@ class SpecialtyVisitStageService : ($stages[1] ?? ($stages[0] ?? null)); } + if ($moduleKey === 'pediatrics') { + $stages = $this->allowedStages($moduleKey); + + return in_array(\App\Services\Care\Pediatrics\PediatricsWorkflowService::STAGE_HISTORY, $stages, true) + ? \App\Services\Care\Pediatrics\PediatricsWorkflowService::STAGE_HISTORY + : ($stages[1] ?? ($stages[0] ?? null)); + } + + if ($moduleKey === 'orthopedics') { + $stages = $this->allowedStages($moduleKey); + + return in_array(\App\Services\Care\Orthopedics\OrthopedicsWorkflowService::STAGE_HISTORY, $stages, true) + ? \App\Services\Care\Orthopedics\OrthopedicsWorkflowService::STAGE_HISTORY + : ($stages[1] ?? ($stages[0] ?? null)); + } + + if ($moduleKey === 'ent') { + $stages = $this->allowedStages($moduleKey); + + return in_array(\App\Services\Care\Ent\EntWorkflowService::STAGE_HISTORY, $stages, true) + ? \App\Services\Care\Ent\EntWorkflowService::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 ab14e60..69b1af1 100644 --- a/config/care_specialty_clinical.php +++ b/config/care_specialty_clinical.php @@ -66,16 +66,25 @@ return [ 'followup' => 'psych_followup', ], 'pediatrics' => [ - 'exam' => 'pediatric_exam', - 'clinical_notes' => 'clinical_note', + 'history' => 'ped_history', + 'growth' => 'pediatric_exam', + 'investigations' => 'ped_investigation', + 'plan' => 'ped_plan', + 'treatment' => 'ped_treatment', ], 'orthopedics' => [ + 'history' => 'ortho_history', 'exam' => 'ortho_exam', - 'clinical_notes' => 'clinical_note', + 'imaging' => 'ortho_imaging', + 'plan' => 'ortho_plan', + 'procedure' => 'ortho_procedure', ], 'ent' => [ + 'history' => 'ent_history', 'exam' => 'ent_exam', - 'clinical_notes' => 'clinical_note', + 'investigations' => 'ent_investigation', + 'plan' => 'ent_plan', + 'procedure' => 'ent_procedure', ], 'oncology' => [ 'review' => 'oncology_review', @@ -480,42 +489,103 @@ return [ ], ], 'pediatrics' => [ + 'ped_history' => [ + ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4], + ['name' => 'birth_history', 'label' => 'Birth / neonatal history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'past_medical', 'label' => 'Past medical history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'], + ['name' => 'feeding', 'label' => 'Feeding / nutrition', 'type' => 'textarea', 'rows' => 2], + ['name' => 'social', 'label' => 'Social / family context', 'type' => 'textarea', 'rows' => 2], + ], 'pediatric_exam' => [ ['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true], ['name' => 'age_months', 'label' => 'Age (months)', 'type' => 'number'], ['name' => 'weight_kg', 'label' => 'Weight (kg)', 'type' => 'number'], ['name' => 'height_cm', 'label' => 'Height / length (cm)', 'type' => 'number'], ['name' => 'head_circumference_cm', 'label' => 'Head circumference (cm)', 'type' => 'number'], + ['name' => 'temperature_c', 'label' => 'Temperature (°C)', 'type' => 'number'], + ['name' => 'growth_concern', 'label' => 'Growth concern', 'type' => 'select', 'options' => ['None', 'Underweight', 'Stunting', 'Wasting', 'Overweight']], ['name' => 'developmental', 'label' => 'Developmental notes', 'type' => 'textarea', 'rows' => 2], ['name' => 'immunization_status', 'label' => 'Immunization status', 'type' => 'text'], ['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'required' => true, 'rows' => 3], ], - 'clinical_note' => [ - ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4], - ['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3], - ['name' => 'working_diagnosis', 'label' => 'Working diagnosis', 'type' => 'text'], - ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 3], + 'ped_investigation' => [ + ['name' => 'investigation', 'label' => 'Investigation', 'type' => 'text', 'required' => true], + ['name' => 'findings', 'label' => 'Findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'impression', 'label' => 'Impression', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], + ], + 'ped_plan' => [ + ['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true], + ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'treatment_planned', 'label' => 'Treatment / immunization planned', 'type' => 'boolean'], + ['name' => 'follow_up', 'label' => 'Follow-up', 'type' => 'text'], + ['name' => 'advice', 'label' => 'Caregiver advice', 'type' => 'textarea', 'rows' => 2], + ], + 'ped_treatment' => [ + ['name' => 'treatment', 'label' => 'Treatment / immunization', 'type' => 'text', 'required' => true], + ['name' => 'vaccine_or_procedure', 'label' => 'Vaccine / procedure details', 'type' => 'textarea', 'rows' => 2], + ['name' => 'site_route', 'label' => 'Site / route', 'type' => 'text'], + ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed uneventfully', 'Completed with complications', 'Deferred', 'Aborted']], + ['name' => 'adverse_events', 'label' => 'Adverse events', 'type' => 'textarea', 'rows' => 2], + ['name' => 'post_treatment_plan', 'label' => 'Post-treatment plan', 'type' => 'textarea', 'rows' => 2], ], ], 'orthopedics' => [ + 'ortho_history' => [ + ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4], + ['name' => 'mechanism', 'label' => 'Mechanism of injury', 'type' => 'textarea', 'rows' => 2], + ['name' => 'past_ortho', 'label' => 'Past orthopedic history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'], + ['name' => 'comorbidities', 'label' => 'Comorbidities', 'type' => 'textarea', 'rows' => 2], + ], 'ortho_exam' => [ ['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true], ['name' => 'side', 'label' => 'Side', 'type' => 'select', 'options' => ['Left', 'Right', 'Bilateral', 'N/A']], ['name' => 'region', 'label' => 'Region / joint', 'type' => 'text', 'required' => true], - ['name' => 'mechanism', 'label' => 'Mechanism of injury', 'type' => 'textarea', 'rows' => 2], ['name' => 'rom', 'label' => 'Range of motion', 'type' => 'textarea', 'rows' => 2], - ['name' => 'neurovascular', 'label' => 'Neurovascular status', 'type' => 'text'], - ['name' => 'imaging', 'label' => 'Imaging findings', 'type' => 'textarea', 'rows' => 2], + ['name' => 'neurovascular', 'label' => 'Neurovascular status', 'type' => 'select', 'options' => ['Intact', 'Sensory deficit', 'Motor deficit', 'Vascular compromise', 'Not assessed']], + ['name' => 'swelling', 'label' => 'Swelling / deformity', 'type' => 'text'], + ['name' => 'exam_findings', 'label' => 'Examination findings', 'type' => 'textarea', 'rows' => 3], ['name' => 'fracture_classification', 'label' => 'Fracture / injury classification', '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], + 'ortho_imaging' => [ + ['name' => 'modality', 'label' => 'Modality', 'type' => 'select', 'required' => true, 'options' => ['X-ray', 'CT', 'MRI', 'Ultrasound', 'Other']], + ['name' => 'region', 'label' => 'Region imaged', 'type' => 'text', 'required' => true], + ['name' => 'findings', 'label' => 'Findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'impression', 'label' => 'Impression', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], + ], + 'ortho_plan' => [ + ['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true], + ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'weight_bearing', 'label' => 'Weight-bearing status', 'type' => 'text'], + ['name' => 'procedure_planned', 'label' => 'Procedure / cast planned', 'type' => 'boolean'], + ['name' => 'follow_up', 'label' => 'Follow-up', 'type' => 'text'], + ['name' => 'advice', 'label' => 'Patient advice', 'type' => 'textarea', 'rows' => 2], + ], + 'ortho_procedure' => [ + ['name' => 'procedure', 'label' => 'Procedure / cast', 'type' => 'text', 'required' => true], + ['name' => 'indication', 'label' => 'Indication', 'type' => 'textarea', 'rows' => 2], + ['name' => 'technique', 'label' => 'Technique / materials', 'type' => 'textarea', 'rows' => 2], + ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed uneventfully', 'Completed with complications', 'Deferred', 'Aborted']], + ['name' => 'post_procedure_plan', 'label' => 'Post-procedure plan', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], ], ], 'ent' => [ + 'ent_history' => [ + ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'required' => true, 'rows' => 4], + ['name' => 'ear_symptoms', 'label' => 'Ear symptoms', 'type' => 'textarea', 'rows' => 2], + ['name' => 'nose_symptoms', 'label' => 'Nose / sinus symptoms', 'type' => 'textarea', 'rows' => 2], + ['name' => 'throat_symptoms', 'label' => 'Throat / voice symptoms', 'type' => 'textarea', 'rows' => 2], + ['name' => 'past_ent', 'label' => 'Past ENT history', 'type' => 'textarea', 'rows' => 2], + ['name' => 'medications', 'label' => 'Medications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'allergies', 'label' => 'Allergies', 'type' => 'text'], + ], 'ent_exam' => [ ['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true], ['name' => 'ear_findings', 'label' => 'Ear findings', 'type' => 'textarea', 'rows' => 2], @@ -523,12 +593,30 @@ return [ ['name' => 'throat_findings', 'label' => 'Throat / larynx findings', 'type' => 'textarea', 'rows' => 2], ['name' => 'hearing', 'label' => 'Hearing assessment', 'type' => 'text'], ['name' => 'neck', 'label' => 'Neck examination', 'type' => 'text'], + ['name' => 'airway_concern', 'label' => 'Airway concern', 'type' => 'boolean'], + ['name' => 'exam_summary', 'label' => 'Exam summary', '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], + 'ent_investigation' => [ + ['name' => 'investigation', 'label' => 'Investigation', 'type' => 'text', 'required' => true], + ['name' => 'findings', 'label' => 'Findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'impression', 'label' => 'Impression', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], + ], + 'ent_plan' => [ + ['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true], + ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'medications', 'label' => 'Medications', '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], + ], + 'ent_procedure' => [ + ['name' => 'procedure', 'label' => 'Procedure', 'type' => 'text', 'required' => true], + ['name' => 'indication', 'label' => 'Indication', 'type' => 'textarea', 'rows' => 2], + ['name' => 'technique', 'label' => 'Technique', 'type' => 'textarea', 'rows' => 2], + ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed uneventfully', 'Completed with complications', 'Deferred', 'Aborted']], + ['name' => 'post_procedure_plan', 'label' => 'Post-procedure plan', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], ], ], 'oncology' => [ diff --git a/config/care_specialty_modules.php b/config/care_specialty_modules.php index 2b4f9c4..ecc446a 100644 --- a/config/care_specialty_modules.php +++ b/config/care_specialty_modules.php @@ -166,6 +166,7 @@ return [ 'queue_keywords' => ['pedia', 'paedia', 'child', 'infant'], 'access' => 'general', 'roles' => ['doctor', 'nurse', 'receptionist'], + 'specialist_keywords' => ['pedia', 'paedia', 'pediatric', 'paediatric', 'child', 'infant'], ], 'orthopedics' => [ 'label' => 'Orthopedics', @@ -179,7 +180,7 @@ return [ 'queue_keywords' => ['ortho', 'fracture', 'bone', 'joint'], 'access' => 'restricted', 'roles' => ['doctor'], - 'support_roles' => [], + 'support_roles' => ['nurse'], 'view_roles' => ['doctor', 'nurse'], 'refer_roles' => ['doctor', 'nurse'], 'specialist_keywords' => ['ortho', 'orthopedic', 'orthopaedic', 'fracture'], @@ -196,7 +197,7 @@ return [ 'queue_keywords' => ['ent', 'ear', 'nose', 'throat', 'otolaryng'], 'access' => 'restricted', 'roles' => ['doctor'], - 'support_roles' => [], + 'support_roles' => ['nurse'], 'view_roles' => ['doctor', 'nurse'], 'refer_roles' => ['doctor', 'nurse'], 'specialist_keywords' => ['ent', 'otolaryng', 'ear', 'nose', 'throat'], diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index c66a632..80ada02 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -402,60 +402,114 @@ return [ ], 'pediatrics' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], - ['code' => 'exam', 'label' => 'Pediatric exam', 'queue_point' => 'chair'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'history', 'label' => 'History', 'queue_point' => 'waiting'], + ['code' => 'exam', 'label' => 'Exam / growth', 'queue_point' => 'chair'], + ['code' => 'investigation', 'label' => 'Investigations', 'queue_point' => 'chair'], + ['code' => 'plan', 'label' => 'Diagnosis & plan', 'queue_point' => 'chair'], + ['code' => 'treatment', 'label' => 'Treatment / immunization', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ ['code' => 'ped.consult', 'label' => 'Pediatric consultation', 'amount_minor' => 5000, 'type' => 'consultation'], + ['code' => 'ped.growth', 'label' => 'Growth assessment', 'amount_minor' => 3000, 'type' => 'consultation'], + ['code' => 'ped.immunization', 'label' => 'Immunization visit', 'amount_minor' => 4000, 'type' => 'procedure'], + ['code' => 'ped.procedure', 'label' => 'Pediatric procedure', 'amount_minor' => 8000, 'type' => 'procedure'], ], 'workspace_tabs' => [ 'overview' => 'Overview', - 'exam' => 'Pediatric exam', - 'clinical_notes' => 'Clinical notes', + 'history' => 'History', + 'growth' => 'Growth / exam', + 'investigations' => 'Investigations', + 'plan' => 'Diagnosis & plan', + 'treatment' => 'Treatment', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'history' => 'history', + 'exam' => 'growth', + 'investigation' => 'investigations', + 'plan' => 'plan', + 'treatment' => 'treatment', + 'completed' => 'overview', + ], ], 'orthopedics' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], - ['code' => 'exam', 'label' => 'Ortho exam', 'queue_point' => 'chair'], - ['code' => 'procedure', 'label' => 'Procedure / casting', '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' => 'imaging', 'label' => 'Imaging', 'queue_point' => 'chair'], + ['code' => 'plan', 'label' => 'Diagnosis & plan', 'queue_point' => 'chair'], + ['code' => 'procedure', 'label' => 'Procedure / cast', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ ['code' => 'ort.consult', 'label' => 'Orthopedic consultation', 'amount_minor' => 6000, 'type' => 'consultation'], + ['code' => 'ort.xray', 'label' => 'Ortho imaging review', 'amount_minor' => 5000, 'type' => 'imaging'], ['code' => 'ort.cast', 'label' => 'Casting / splinting', 'amount_minor' => 10000, 'type' => 'procedure'], + ['code' => 'ort.procedure', 'label' => 'Orthopedic procedure', 'amount_minor' => 25000, 'type' => 'procedure'], ], 'workspace_tabs' => [ 'overview' => 'Overview', - 'exam' => 'Ortho exam', - 'clinical_notes' => 'Clinical notes', + 'history' => 'History', + 'exam' => 'Exam', + 'imaging' => 'Imaging', + 'plan' => 'Diagnosis & plan', + 'procedure' => 'Procedure', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', ], + 'stage_tabs' => [ + 'check_in' => 'overview', + 'history' => 'history', + 'exam' => 'exam', + 'imaging' => 'imaging', + 'plan' => 'plan', + 'procedure' => 'procedure', + 'completed' => 'overview', + ], ], 'ent' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting', 'queue_point' => 'waiting'], - ['code' => 'exam', 'label' => 'ENT exam', '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' => 'procedure', 'label' => 'Procedure', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ ['code' => 'ent.consult', 'label' => 'ENT consultation', 'amount_minor' => 6000, 'type' => 'consultation'], + ['code' => 'ent.audiology', 'label' => 'Hearing assessment', 'amount_minor' => 5000, 'type' => 'procedure'], ['code' => 'ent.procedure', 'label' => 'ENT procedure', 'amount_minor' => 12000, 'type' => 'procedure'], + ['code' => 'ent.endoscopy', 'label' => 'Nasal / laryngeal endoscopy', 'amount_minor' => 15000, 'type' => 'procedure'], ], 'workspace_tabs' => [ 'overview' => 'Overview', - 'exam' => 'ENT exam', - 'clinical_notes' => 'Clinical notes', + 'history' => 'History', + 'exam' => 'Exam', + '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', + ], ], 'oncology' => [ 'stages' => [ diff --git a/resources/views/care/specialty/ent/print.blade.php b/resources/views/care/specialty/ent/print.blade.php new file mode 100644 index 0000000..d070696 --- /dev/null +++ b/resources/views/care/specialty/ent/print.blade.php @@ -0,0 +1,91 @@ + + + + + ENT 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'] ?? '—' }}
+
Ear
{{ $history->payload['ear_symptoms'] ?? '—' }}
+
Nose
{{ $history->payload['nose_symptoms'] ?? '—' }}
+
Throat
{{ $history->payload['throat_symptoms'] ?? '—' }}
+
Medications
{{ $history->payload['medications'] ?? '—' }}
+
+ @else +

No history recorded.

+ @endif + +

Exam

+ @if ($exam) +
+
Complaint
{{ $exam->payload['chief_complaint'] ?? '—' }}
+
Ear
{{ $exam->payload['ear_findings'] ?? '—' }}
+
Nose
{{ $exam->payload['nose_findings'] ?? '—' }}
+
Throat
{{ $exam->payload['throat_findings'] ?? '—' }}
+
Hearing
{{ $exam->payload['hearing'] ?? '—' }}
+
Airway
{{ ! empty($exam->payload['airway_concern']) ? 'Concern flagged' : 'No concern' }}
+
+ @else +

No exam recorded.

+ @endif + +

Investigations

+ @if ($investigation) +
+
Investigation
{{ $investigation->payload['investigation'] ?? '—' }}
+
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/ent/reports.blade.php b/resources/views/care/specialty/ent/reports.blade.php new file mode 100644 index 0000000..90da9af --- /dev/null +++ b/resources/views/care/specialty/ent/reports.blade.php @@ -0,0 +1,88 @@ + +
+
+
+

ENT reports

+

Arrivals, airway concerns, diagnoses, procedures, length of stay, and ENT revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

Airway concern open

+

{{ number_format($report['airway_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 +
+
+
+ +
+

ENT service revenue

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

ENT overview

+

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

+
+ +
+ +
+
+

Complaint

+

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

+

{{ ! empty($exam['airway_concern']) ? 'Airway concern' : 'Airway OK' }}

+
+
+

Exam

+

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

+

Hearing {{ $exam['hearing'] ?? '—' }}

+
+
+

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/ent/workspace-procedure.blade.php b/resources/views/care/specialty/ent/workspace-procedure.blade.php new file mode 100644 index 0000000..b24d201 --- /dev/null +++ b/resources/views/care/specialty/ent/workspace-procedure.blade.php @@ -0,0 +1,70 @@ +@php + $record = $entProcedure; + $payload = $record?->payload ?? []; + $canManage = $canManageClinical ?? $canConsult ?? false; +@endphp + +
+
+
+

Procedure

+

Completed procedures can close the ENT 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/orthopedics/print.blade.php b/resources/views/care/specialty/orthopedics/print.blade.php new file mode 100644 index 0000000..5e9d556 --- /dev/null +++ b/resources/views/care/specialty/orthopedics/print.blade.php @@ -0,0 +1,90 @@ + + + + + Orthopedics 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'] ?? '—' }}
+
Mechanism
{{ $history->payload['mechanism'] ?? '—' }}
+
Past ortho
{{ $history->payload['past_ortho'] ?? '—' }}
+
Medications
{{ $history->payload['medications'] ?? '—' }}
+
+ @else +

No history recorded.

+ @endif + +

Exam

+ @if ($exam) +
+
Complaint
{{ $exam->payload['chief_complaint'] ?? '—' }}
+
Side / region
{{ $exam->payload['side'] ?? '—' }} · {{ $exam->payload['region'] ?? '—' }}
+
Neurovascular
{{ $exam->payload['neurovascular'] ?? '—' }}
+
Classification
{{ $exam->payload['fracture_classification'] ?? '—' }}
+
Findings
{{ $exam->payload['exam_findings'] ?? '—' }}
+
+ @else +

No exam recorded.

+ @endif + +

Imaging

+ @if ($imaging) +
+
Modality
{{ $imaging->payload['modality'] ?? '—' }}
+
Region
{{ $imaging->payload['region'] ?? '—' }}
+
Findings
{{ $imaging->payload['findings'] ?? '—' }}
+
Impression
{{ $imaging->payload['impression'] ?? '—' }}
+
+ @else +

No imaging recorded.

+ @endif + +

Diagnosis & plan

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

No plan recorded.

+ @endif + +

Procedure / cast

+ @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/orthopedics/reports.blade.php b/resources/views/care/specialty/orthopedics/reports.blade.php new file mode 100644 index 0000000..8ba2785 --- /dev/null +++ b/resources/views/care/specialty/orthopedics/reports.blade.php @@ -0,0 +1,88 @@ + +
+
+
+

Orthopedics reports

+

Arrivals, neurovascular compromise, diagnoses, procedures, length of stay, and orthopedics revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

N/V compromise open

+

{{ number_format($report['nv_compromise_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 +
+
+
+ +
+

Orthopedics service revenue

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

Orthopedics overview

+

History, exam, imaging, and care plan for this episode.

+
+ +
+ +
+
+

Complaint

+

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

+

{{ $exam['side'] ?? '—' }} {{ $exam['region'] ?? '' }}

+
+
+

Neurovascular

+

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

+

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

+
+
+

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/orthopedics/workspace-procedure.blade.php b/resources/views/care/specialty/orthopedics/workspace-procedure.blade.php new file mode 100644 index 0000000..a7a4280 --- /dev/null +++ b/resources/views/care/specialty/orthopedics/workspace-procedure.blade.php @@ -0,0 +1,70 @@ +@php + $record = $orthopedicsProcedure; + $payload = $record?->payload ?? []; + $canManage = $canManageClinical ?? $canConsult ?? false; +@endphp + +
+
+
+

Procedure / cast

+

Completed procedures can close the orthopedics 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/partials/actions-menu.blade.php b/resources/views/care/specialty/partials/actions-menu.blade.php index 00b41b9..6a6c55d 100644 --- a/resources/views/care/specialty/partials/actions-menu.blade.php +++ b/resources/views/care/specialty/partials/actions-menu.blade.php @@ -50,6 +50,9 @@ 'radiology' => 'check_in', 'cardiology' => 'check_in', 'psychiatry' => 'check_in', + 'pediatrics' => 'check_in', + 'orthopedics' => 'check_in', + 'ent' => 'check_in', default => collect($stages ?? [])->pluck('code')->first(), }; $defaultStartLabel = match ($moduleKey) { @@ -62,6 +65,9 @@ 'radiology' => 'Start check-in', 'cardiology' => 'Start check-in', 'psychiatry' => 'Start check-in', + 'pediatrics' => 'Start check-in', + 'orthopedics' => 'Start check-in', + 'ent' => 'Start check-in', default => 'Start', }; $currentStage = $workspaceVisit?->specialty_stage; diff --git a/resources/views/care/specialty/pediatrics/print.blade.php b/resources/views/care/specialty/pediatrics/print.blade.php new file mode 100644 index 0000000..6de7be4 --- /dev/null +++ b/resources/views/care/specialty/pediatrics/print.blade.php @@ -0,0 +1,90 @@ + + + + + Pediatrics 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'] ?? '—' }}
+
Birth history
{{ $history->payload['birth_history'] ?? '—' }}
+
Past medical
{{ $history->payload['past_medical'] ?? '—' }}
+
Feeding
{{ $history->payload['feeding'] ?? '—' }}
+
Medications
{{ $history->payload['medications'] ?? '—' }}
+
+ @else +

No history recorded.

+ @endif + +

Exam / growth

+ @if ($exam) +
+
Complaint
{{ $exam->payload['chief_complaint'] ?? '—' }}
+
Weight / height
{{ $exam->payload['weight_kg'] ?? '—' }} kg · {{ $exam->payload['height_cm'] ?? '—' }} cm
+
Growth concern
{{ $exam->payload['growth_concern'] ?? '—' }}
+
Immunization
{{ $exam->payload['immunization_status'] ?? '—' }}
+
Exam
{{ $exam->payload['examination'] ?? '—' }}
+
+ @else +

No exam recorded.

+ @endif + +

Investigations

+ @if ($investigation) +
+
Investigation
{{ $investigation->payload['investigation'] ?? '—' }}
+
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 + +

Treatment / immunization

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

No treatment recorded.

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

Pediatrics reports

+

Arrivals, growth concerns, diagnoses, treatments, length of stay, and pediatrics revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

Growth concern open

+

{{ number_format($report['growth_concern_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 +
+
+ +
+

Treatments

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

Pediatrics service revenue

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

Pediatrics overview

+

History, growth / exam, investigations, and care plan for this episode.

+
+ +
+ +
+
+

Complaint

+

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

+

Growth {{ $exam['growth_concern'] ?? '—' }}

+
+
+

Growth

+

+ {{ $exam['weight_kg'] ?? '—' }} kg · {{ $exam['height_cm'] ?? '—' }} cm +

+

HC {{ $exam['head_circumference_cm'] ?? '—' }} · Temp {{ $exam['temperature_c'] ?? '—' }}°C

+
+
+

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/pediatrics/workspace-treatment.blade.php b/resources/views/care/specialty/pediatrics/workspace-treatment.blade.php new file mode 100644 index 0000000..ac24b7a --- /dev/null +++ b/resources/views/care/specialty/pediatrics/workspace-treatment.blade.php @@ -0,0 +1,70 @@ +@php + $record = $pediatricsTreatment; + $payload = $record?->payload ?? []; + $canManage = $canManageClinical ?? $canConsult ?? false; +@endphp + +
+
+
+

Treatment / immunization

+

Completed treatments can close the pediatrics visit.

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

No treatment recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index 5287239..7cb801c 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -73,6 +73,12 @@ @include('care.specialty.cardiology.workspace-'.$activeTab) @elseif ($moduleKey === 'psychiatry' && in_array($activeTab, ['overview', 'followup'], true)) @include('care.specialty.psychiatry.workspace-'.$activeTab) + @elseif ($moduleKey === 'pediatrics' && in_array($activeTab, ['overview', 'treatment'], true)) + @include('care.specialty.pediatrics.workspace-'.$activeTab) + @elseif ($moduleKey === 'orthopedics' && in_array($activeTab, ['overview', 'procedure'], true)) + @include('care.specialty.orthopedics.workspace-'.$activeTab) + @elseif ($moduleKey === 'ent' && in_array($activeTab, ['overview', 'procedure'], true)) + @include('care.specialty.ent.workspace-'.$activeTab) @elseif ($activeTab === 'billing')

diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index d2eead4..bf96d7e 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -52,6 +52,15 @@ @if ($moduleKey === 'psychiatry') Reports @endif + @if ($moduleKey === 'pediatrics') + Reports + @endif + @if ($moduleKey === 'orthopedics') + Reports + @endif + @if ($moduleKey === 'ent') + Reports + @endif History @if ($canManageClinical ?? $canManageSpecialty ?? true) Billing diff --git a/routes/web.php b/routes/web.php index 142ec02..14901b5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -47,6 +47,9 @@ use App\Http\Controllers\Care\MaternityWorkspaceController; use App\Http\Controllers\Care\RadiologyWorkspaceController; use App\Http\Controllers\Care\CardiologyWorkspaceController; use App\Http\Controllers\Care\PsychiatryWorkspaceController; +use App\Http\Controllers\Care\PediatricsWorkspaceController; +use App\Http\Controllers\Care\OrthopedicsWorkspaceController; +use App\Http\Controllers\Care\EntWorkspaceController; use App\Http\Controllers\Care\SpecialtyModuleController; use App\Http\Controllers\Care\VisitWorkflowController; use App\Http\Controllers\NotificationController; @@ -135,6 +138,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/specialty/radiology/reports', [RadiologyWorkspaceController::class, 'reports'])->name('care.specialty.radiology.reports'); Route::get('/specialty/cardiology/reports', [CardiologyWorkspaceController::class, 'reports'])->name('care.specialty.cardiology.reports'); Route::get('/specialty/psychiatry/reports', [PsychiatryWorkspaceController::class, 'reports'])->name('care.specialty.psychiatry.reports'); + Route::get('/specialty/pediatrics/reports', [PediatricsWorkspaceController::class, 'reports'])->name('care.specialty.pediatrics.reports'); + Route::get('/specialty/orthopedics/reports', [OrthopedicsWorkspaceController::class, 'reports'])->name('care.specialty.orthopedics.reports'); + Route::get('/specialty/ent/reports', [EntWorkspaceController::class, 'reports'])->name('care.specialty.ent.reports'); Route::get('/specialty/{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'); @@ -186,6 +192,15 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/specialty/psychiatry/workspace/{visit}/stage', [PsychiatryWorkspaceController::class, 'setStage'])->name('care.specialty.psychiatry.stage'); Route::post('/specialty/psychiatry/workspace/{visit}/followup', [PsychiatryWorkspaceController::class, 'saveFollowUp'])->name('care.specialty.psychiatry.followup'); Route::get('/specialty/psychiatry/workspace/{visit}/print', [PsychiatryWorkspaceController::class, 'printSummary'])->name('care.specialty.psychiatry.print'); + Route::post('/specialty/pediatrics/workspace/{visit}/stage', [PediatricsWorkspaceController::class, 'setStage'])->name('care.specialty.pediatrics.stage'); + Route::post('/specialty/pediatrics/workspace/{visit}/treatment', [PediatricsWorkspaceController::class, 'saveTreatment'])->name('care.specialty.pediatrics.treatment'); + Route::get('/specialty/pediatrics/workspace/{visit}/print', [PediatricsWorkspaceController::class, 'printSummary'])->name('care.specialty.pediatrics.print'); + Route::post('/specialty/orthopedics/workspace/{visit}/stage', [OrthopedicsWorkspaceController::class, 'setStage'])->name('care.specialty.orthopedics.stage'); + Route::post('/specialty/orthopedics/workspace/{visit}/procedure', [OrthopedicsWorkspaceController::class, 'saveProcedure'])->name('care.specialty.orthopedics.procedure'); + Route::get('/specialty/orthopedics/workspace/{visit}/print', [OrthopedicsWorkspaceController::class, 'printSummary'])->name('care.specialty.orthopedics.print'); + Route::post('/specialty/ent/workspace/{visit}/stage', [EntWorkspaceController::class, 'setStage'])->name('care.specialty.ent.stage'); + Route::post('/specialty/ent/workspace/{visit}/procedure', [EntWorkspaceController::class, 'saveProcedure'])->name('care.specialty.ent.procedure'); + Route::get('/specialty/ent/workspace/{visit}/print', [EntWorkspaceController::class, 'printSummary'])->name('care.specialty.ent.print'); Route::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/CareEntSuiteTest.php b/tests/Feature/CareEntSuiteTest.php new file mode 100644 index 0000000..a8de27a --- /dev/null +++ b/tests/Feature/CareEntSuiteTest.php @@ -0,0 +1,234 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'ent-owner', + 'name' => 'Owner', + 'email' => 'ent-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'ENT Clinic', + 'slug' => 'ent-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, + 'ent', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'ent') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-ENT-SUITE', + 'first_name' => 'Esi', + 'last_name' => 'Throat', + 'gender' => 'female', + 'date_of_birth' => '1988-09-02', + ]); + + $this->visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + 'specialty_stage' => 'check_in', + ]); + + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'department_id' => $department->id, + 'visit_id' => $this->visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_IN_CONSULTATION, + 'scheduled_at' => now(), + 'waiting_at' => now(), + 'checked_in_at' => now(), + 'started_at' => now(), + ]); + } + + public function test_overview_and_exam_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'ent', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('ENT overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Start history') + ->assertSee('ENT reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'ent', + 'visit' => $this->visit, + 'tab' => 'exam', + ])) + ->assertOk() + ->assertSee('Chief complaint') + ->assertSee('Airway concern'); + } + + public function test_exam_sets_stage_and_airway_alert(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'ent', + 'visit' => $this->visit, + ]), [ + 'tab' => 'exam', + 'payload' => [ + 'chief_complaint' => 'Sore throat with muffled voice', + 'throat_findings' => 'Unilateral tonsillar bulge', + 'airway_concern' => '1', + 'exam_summary' => 'Possible peritonsillar abscess', + ], + ]) + ->assertRedirect(); + + $this->assertSame('exam', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'ent_exam') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('ent.airway_concern', $codes); + } + + public function test_stage_advance_and_procedure_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.ent.stage', $this->visit), [ + 'stage' => 'procedure', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'ent', + 'visit' => $this->visit, + 'tab' => 'procedure', + ])); + + $this->assertSame('procedure', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.ent.procedure', $this->visit), [ + 'tab' => 'procedure', + 'payload' => [ + 'procedure' => 'Needle aspiration of PTA', + 'indication' => 'Peritonsillar abscess', + 'outcome' => 'Completed uneventfully', + 'post_procedure_plan' => 'IV antibiotics; airway observation', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'ent', + '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' => 'ent_procedure', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.ent.reports')) + ->assertOk() + ->assertSee('ENT reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.ent.print', $this->visit)) + ->assertOk() + ->assertSee('ENT 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', 'ent')) + ->assertOk() + ->assertDontSee('ENT overview'); + } +} diff --git a/tests/Feature/CareOrthopedicsSuiteTest.php b/tests/Feature/CareOrthopedicsSuiteTest.php new file mode 100644 index 0000000..5aa6120 --- /dev/null +++ b/tests/Feature/CareOrthopedicsSuiteTest.php @@ -0,0 +1,236 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'ort-owner', + 'name' => 'Owner', + 'email' => 'ort-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Orthopedics Clinic', + 'slug' => 'orthopedics-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, + 'orthopedics', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'orthopedics') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-ORT-SUITE', + 'first_name' => 'Kofi', + 'last_name' => 'Fracture', + 'gender' => 'male', + 'date_of_birth' => '1990-06-10', + ]); + + $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' => 'orthopedics', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Orthopedics overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Procedure / cast') + ->assertSee('Start history') + ->assertSee('Orthopedics reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'orthopedics', + 'visit' => $this->visit, + 'tab' => 'exam', + ])) + ->assertOk() + ->assertSee('Chief complaint') + ->assertSee('Neurovascular status'); + } + + public function test_exam_sets_stage_and_nv_alert(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'orthopedics', + 'visit' => $this->visit, + ]), [ + 'tab' => 'exam', + 'payload' => [ + 'chief_complaint' => 'Wrist deformity', + 'side' => 'Right', + 'region' => 'Distal radius', + 'neurovascular' => 'Sensory deficit', + 'exam_findings' => 'Closed injury', + ], + ]) + ->assertRedirect(); + + $this->assertSame('exam', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'ortho_exam') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('ort.nv_deficit', $codes); + } + + public function test_stage_advance_and_procedure_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.orthopedics.stage', $this->visit), [ + 'stage' => 'procedure', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'orthopedics', + 'visit' => $this->visit, + 'tab' => 'procedure', + ])); + + $this->assertSame('procedure', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.orthopedics.procedure', $this->visit), [ + 'tab' => 'procedure', + 'payload' => [ + 'procedure' => 'Closed reduction + cast', + 'indication' => 'Displaced distal radius fracture', + 'outcome' => 'Completed uneventfully', + 'post_procedure_plan' => 'Elevate; cast check in 1 week', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'orthopedics', + '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' => 'ortho_procedure', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.orthopedics.reports')) + ->assertOk() + ->assertSee('Orthopedics reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.orthopedics.print', $this->visit)) + ->assertOk() + ->assertSee('Orthopedics 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', 'orthopedics')) + ->assertOk() + ->assertDontSee('Orthopedics overview'); + } +} diff --git a/tests/Feature/CarePediatricsSuiteTest.php b/tests/Feature/CarePediatricsSuiteTest.php new file mode 100644 index 0000000..6248137 --- /dev/null +++ b/tests/Feature/CarePediatricsSuiteTest.php @@ -0,0 +1,238 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'ped-owner', + 'name' => 'Owner', + 'email' => 'ped-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Pediatrics Clinic', + 'slug' => 'pediatrics-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, + 'pediatrics', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'pediatrics') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-PED-SUITE', + 'first_name' => 'Ama', + 'last_name' => 'Child', + 'gender' => 'female', + 'date_of_birth' => '2024-01-15', + ]); + + $this->visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + 'specialty_stage' => 'check_in', + ]); + + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'department_id' => $department->id, + 'visit_id' => $this->visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_IN_CONSULTATION, + 'scheduled_at' => now(), + 'waiting_at' => now(), + 'checked_in_at' => now(), + 'started_at' => now(), + ]); + } + + public function test_overview_and_growth_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'pediatrics', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Pediatrics overview') + ->assertSee('data-care-stage-bar', false) + ->assertSee('Exam / growth') + ->assertSee('Start history') + ->assertSee('Pediatrics reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'pediatrics', + 'visit' => $this->visit, + 'tab' => 'growth', + ])) + ->assertOk() + ->assertSee('Chief complaint') + ->assertSee('Growth concern'); + } + + public function test_exam_sets_stage_and_growth_alert(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'pediatrics', + 'visit' => $this->visit, + ]), [ + 'tab' => 'growth', + 'payload' => [ + 'chief_complaint' => 'Fever', + 'weight_kg' => 8.2, + 'temperature_c' => 39.2, + 'growth_concern' => 'Underweight', + 'examination' => 'Irritable; mild dehydration', + ], + ]) + ->assertRedirect(); + + $this->assertSame('exam', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'pediatric_exam') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('ped.growth_concern', $codes); + $this->assertContains('ped.fever_high', $codes); + } + + public function test_stage_advance_and_treatment_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.pediatrics.stage', $this->visit), [ + 'stage' => 'treatment', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'pediatrics', + 'visit' => $this->visit, + 'tab' => 'treatment', + ])); + + $this->assertSame('treatment', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.pediatrics.treatment', $this->visit), [ + 'tab' => 'treatment', + 'payload' => [ + 'treatment' => 'Measles immunization', + 'vaccine_or_procedure' => 'Measles dose 1', + 'site_route' => 'Left upper arm / IM', + 'outcome' => 'Completed uneventfully', + 'post_treatment_plan' => 'Observe 15 minutes; fever advice', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'pediatrics', + 'visit' => $this->visit, + 'tab' => 'treatment', + ])); + + $this->assertSame('completed', $this->visit->fresh()->specialty_stage); + $this->assertSame(Visit::STATUS_COMPLETED, $this->visit->fresh()->status); + $this->assertDatabaseHas('care_specialty_clinical_records', [ + 'visit_id' => $this->visit->id, + 'record_type' => 'ped_treatment', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.pediatrics.reports')) + ->assertOk() + ->assertSee('Pediatrics reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.pediatrics.print', $this->visit)) + ->assertOk() + ->assertSee('Pediatrics 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', 'pediatrics')) + ->assertOk() + ->assertDontSee('Pediatrics overview'); + } +}