From 1d3db4f803456a7979004e980c235083cdf6f699 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sun, 19 Jul 2026 15:19:04 +0000 Subject: [PATCH] Add full Eye Care (ophthalmology) specialty clinical suite. Mirror Emergency/Blood Bank depth with stages, workspace tabs, workflow/analytics services, reports/print, demo clinical seed, and feature tests. Co-authored-by: Cursor --- .../Care/OphthalmologyWorkspaceController.php | 211 ++++++++++++++++ .../Care/SpecialtyModuleController.php | 79 +++++- app/Services/Care/DemoTenantSeeder.php | 129 ++++++++++ .../OphthalmologyAnalyticsService.php | 169 +++++++++++++ .../OphthalmologyWorkflowService.php | 105 ++++++++ .../Care/SpecialtyClinicalRecordService.php | 46 ++++ app/Services/Care/SpecialtyShellService.php | 3 +- .../Care/SpecialtyVisitStageService.php | 8 + config/care.php | 1 + config/care_specialty_clinical.php | 65 +++-- config/care_specialty_shell.php | 26 +- .../specialty/ophthalmology/print.blade.php | 107 ++++++++ .../specialty/ophthalmology/reports.blade.php | 88 +++++++ .../workspace-overview.blade.php | 126 ++++++++++ .../ophthalmology/workspace-treat.blade.php | 70 ++++++ .../specialty/sections/workspace.blade.php | 2 + .../views/care/specialty/shell.blade.php | 3 + routes/web.php | 5 + tests/Feature/CareOphthalmologySuiteTest.php | 231 ++++++++++++++++++ tests/Feature/CareSpecialtyClinicalTest.php | 8 +- 20 files changed, 1454 insertions(+), 28 deletions(-) create mode 100644 app/Http/Controllers/Care/OphthalmologyWorkspaceController.php create mode 100644 app/Services/Care/Ophthalmology/OphthalmologyAnalyticsService.php create mode 100644 app/Services/Care/Ophthalmology/OphthalmologyWorkflowService.php create mode 100644 resources/views/care/specialty/ophthalmology/print.blade.php create mode 100644 resources/views/care/specialty/ophthalmology/reports.blade.php create mode 100644 resources/views/care/specialty/ophthalmology/workspace-overview.blade.php create mode 100644 resources/views/care/specialty/ophthalmology/workspace-treat.blade.php create mode 100644 tests/Feature/CareOphthalmologySuiteTest.php diff --git a/app/Http/Controllers/Care/OphthalmologyWorkspaceController.php b/app/Http/Controllers/Care/OphthalmologyWorkspaceController.php new file mode 100644 index 0000000..60dfb8e --- /dev/null +++ b/app/Http/Controllers/Care/OphthalmologyWorkspaceController.php @@ -0,0 +1,211 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'ophthalmology'), 403); + } + + protected function assertOphthalmologyManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'ophthalmology'), 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, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertOphthalmologyManage($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'ophthalmology', + $validated['stage'], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'ophthalmology', 'visit' => $visit, 'tab' => 'overview']) + ->with('success', 'Visit stage updated.'); + } + + public function saveProcedure( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyClinicalRecordService $clinical, + SpecialtyVisitStageService $stages, + OphthalmologyWorkflowService $workflow, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertOphthalmologyManage($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $payload = (array) $request->input('payload', []); + foreach ($clinical->fieldsFor('ophthalmology', 'eye_procedure') as $field) { + if (($field['type'] ?? '') === 'boolean') { + $payload[$field['name']] = $request->boolean('payload.'.$field['name']); + } + } + $request->merge(['payload' => $payload, 'tab' => 'treat']); + + $validated = $request->validate(array_merge([ + 'tab' => ['required', 'string'], + ], $clinical->validationRules('ophthalmology', 'eye_procedure'))); + + $record = $clinical->upsert( + $organization, + $visit, + 'ophthalmology', + 'eye_procedure', + $clinical->payloadFromRequest($validated), + $owner, + $owner, + OphthalmologyWorkflowService::STAGE_TREATMENT, + SpecialtyClinicalRecord::STATUS_COMPLETED, + ); + + try { + $stages->setStage( + $organization, + $visit, + 'ophthalmology', + OphthalmologyWorkflowService::STAGE_TREATMENT, + $owner, + $owner, + ); + } catch (\InvalidArgumentException) { + // Stage already treatment or map empty. + } + + if ($workflow->shouldCompleteVisit($record->payload ?? [])) { + try { + $stages->setStage( + $organization, + $visit, + 'ophthalmology', + OphthalmologyWorkflowService::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' => 'ophthalmology', 'visit' => $visit, 'tab' => 'treat']) + ->with('success', 'Procedure / treatment saved.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + OphthalmologyAnalyticsService $analytics, + ): View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertOphthalmologyAccess($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.ophthalmology.reports', [ + 'moduleKey' => 'ophthalmology', + 'definition' => $modules->definition('ophthalmology'), + 'shellNav' => $shell->navItems('ophthalmology'), + '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->assertOphthalmologyAccess($request, $modules); + $this->assertVisit($request, $visit); + + $visit->loadMissing(['patient', 'appointment.practitioner', 'branch']); + + return view('care.specialty.ophthalmology.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'refraction' => $clinical->findForVisit($visit, 'ophthalmology', 'refraction'), + 'exam' => $clinical->findForVisit($visit, 'ophthalmology', 'eye_exam'), + 'investigation' => $clinical->findForVisit($visit, 'ophthalmology', 'eye_investigation'), + 'plan' => $clinical->findForVisit($visit, 'ophthalmology', 'eye_plan'), + 'procedure' => $clinical->findForVisit($visit, 'ophthalmology', 'eye_procedure'), + ]); + } +} diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 5ffbad4..307dfb4 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -153,6 +153,7 @@ class SpecialtyModuleController extends Controller 'dentistry' => 'odontogram', 'emergency' => 'triage', 'blood_bank' => 'requests', + 'ophthalmology' => 'refraction', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -210,7 +211,7 @@ class SpecialtyModuleController extends Controller } catch (\InvalidArgumentException) { // Stage map may be empty for some modules. } - } elseif ($nextStage && in_array($visit->specialty_stage, ['waiting', 'arrival', 'request'], true)) { + } elseif ($nextStage && in_array($visit->specialty_stage, ['waiting', 'arrival', 'request', 'check_in'], true)) { try { $stageService->setStage($organization, $visit, $module, $nextStage, $owner, $owner); $visit = $visit->fresh(); @@ -224,6 +225,7 @@ class SpecialtyModuleController extends Controller 'dentistry' => 'odontogram', 'emergency' => 'triage', 'blood_bank' => 'requests', + 'ophthalmology' => 'refraction', default => (collect(array_keys($shellTabs)) ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) ?? 'clinical_notes'), @@ -356,6 +358,57 @@ class SpecialtyModuleController extends Controller } } + if ($module === 'ophthalmology' && in_array($recordType, ['refraction', 'eye_exam', 'eye_investigation', 'eye_plan'], true)) { + $workflow = app(\App\Services\Care\Ophthalmology\OphthalmologyWorkflowService::class); + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $suggested = match ($recordType) { + 'refraction' => $workflow->stageFromRefraction($record->payload ?? []), + 'eye_exam' => $workflow->stageFromExam($record->payload ?? []), + 'eye_investigation' => \App\Services\Care\Ophthalmology\OphthalmologyWorkflowService::STAGE_INVESTIGATION, + 'eye_plan' => $workflow->stageFromPlan($record->payload ?? []), + default => null, + }; + $current = $visit->specialty_stage; + $early = ['check_in', 'history', 'waiting', null, '']; + if ($suggested && (! $current || in_array($current, $early, true))) { + try { + $stageService->setStage( + $organization, + $visit, + 'ophthalmology', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } elseif ($suggested && $suggested !== $current) { + $order = [ + 'check_in' => 0, + 'history' => 1, + 'refraction' => 2, + 'exam' => 3, + 'investigation' => 4, + 'plan' => 5, + 'treatment' => 6, + 'completed' => 7, + ]; + if (($order[$suggested] ?? 0) > ($order[$current] ?? 0)) { + try { + $stageService->setStage( + $organization, + $visit, + 'ophthalmology', + $suggested, + $this->ownerRef($request), + $this->ownerRef($request), + ); + } catch (\InvalidArgumentException) { + } + } + } + } + return redirect() ->route('care.specialty.workspace', [ 'module' => $module, @@ -749,6 +802,13 @@ class SpecialtyModuleController extends Controller $bloodBankTransfusion = null; $bloodBankStageCodes = []; $bloodBankStageFlow = []; + $ophthalmologyRefraction = null; + $ophthalmologyExam = null; + $ophthalmologyInvestigation = null; + $ophthalmologyPlan = null; + $ophthalmologyProcedure = null; + $ophthalmologyStageCodes = []; + $ophthalmologyStageFlow = []; $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); $clinical = app(SpecialtyClinicalRecordService::class); @@ -855,6 +915,16 @@ class SpecialtyModuleController extends Controller $bloodBankStageFlow = app(\App\Services\Care\BloodBank\BloodBankWorkflowService::class)->stageFlow(); } + if ($module === 'ophthalmology') { + $ophthalmologyRefraction = $clinical->findForVisit($workspaceVisit, 'ophthalmology', 'refraction'); + $ophthalmologyExam = $clinical->findForVisit($workspaceVisit, 'ophthalmology', 'eye_exam'); + $ophthalmologyInvestigation = $clinical->findForVisit($workspaceVisit, 'ophthalmology', 'eye_investigation'); + $ophthalmologyPlan = $clinical->findForVisit($workspaceVisit, 'ophthalmology', 'eye_plan'); + $ophthalmologyProcedure = $clinical->findForVisit($workspaceVisit, 'ophthalmology', 'eye_procedure'); + $ophthalmologyStageCodes = collect($shell->stages('ophthalmology'))->pluck('code')->all(); + $ophthalmologyStageFlow = app(\App\Services\Care\Ophthalmology\OphthalmologyWorkflowService::class)->stageFlow(); + } + if ($patientHeader !== null) { $patientHeader['clinical_alerts'] = $clinicalAlerts; } @@ -940,6 +1010,13 @@ class SpecialtyModuleController extends Controller 'bloodBankTransfusion' => $bloodBankTransfusion, 'bloodBankStageCodes' => $bloodBankStageCodes, 'bloodBankStageFlow' => $bloodBankStageFlow, + 'ophthalmologyRefraction' => $ophthalmologyRefraction, + 'ophthalmologyExam' => $ophthalmologyExam, + 'ophthalmologyInvestigation' => $ophthalmologyInvestigation, + 'ophthalmologyPlan' => $ophthalmologyPlan, + 'ophthalmologyProcedure' => $ophthalmologyProcedure, + 'ophthalmologyStageCodes' => $ophthalmologyStageCodes, + 'ophthalmologyStageFlow' => $ophthalmologyStageFlow, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'branchLabel' => $branchLabel, diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 0d4b213..6e0b49c 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -1014,6 +1014,7 @@ class DemoTenantSeeder $this->seedDentistryClinicalDemo($organization, $ownerRef); $this->seedBloodBankInventoryDemo($organization, $ownerRef); + $this->seedOphthalmologyClinicalDemo($organization, $ownerRef); return $count; } @@ -1113,6 +1114,134 @@ class DemoTenantSeeder ]; } + /** + * Seed refraction + eye exam (+ optional plan) on open ophthalmology visits. + */ + private function seedOphthalmologyClinicalDemo(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', 'ophthalmology') + ->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; + } + + $elevated = $index === 0; + $clinical->upsert( + $organization, + $visit, + 'ophthalmology', + 'refraction', + [ + 'va_od' => $elevated ? '6/60' : '6/9', + 'va_os' => $elevated ? '6/36' : '6/6', + 'va_od_near' => 'N8', + 'va_os_near' => 'N6', + 'sphere_od' => $elevated ? '-2.50' : '-0.75', + 'cylinder_od' => '-0.50', + 'axis_od' => '90', + 'sphere_os' => $elevated ? '-1.75' : '-0.50', + 'cylinder_os' => '-0.25', + 'axis_os' => '85', + 'add' => '+1.50', + 'pd' => 62, + 'notes' => 'Demo refraction for eye care suite.', + ], + $ownerRef, + $ownerRef, + 'refraction', + ); + + $clinical->upsert( + $organization, + $visit, + 'ophthalmology', + 'eye_exam', + [ + 'chief_complaint' => $elevated ? 'Blurry vision and headache' : 'Routine eye exam', + 'history' => $elevated + ? 'Gradual reduction in vision over 2 weeks; intermittent haloes.' + : 'Annual review; no acute symptoms.', + 'va_od' => $elevated ? '6/60' : '6/9', + 'va_os' => $elevated ? '6/36' : '6/6', + 'iop_od' => $elevated ? 32 : 16, + 'iop_os' => $elevated ? 28 : 15, + 'pupils' => 'Equal, reactive', + 'extraocular' => 'Full', + 'anterior_segment' => $elevated ? 'Quiet; possible early cataract OD' : 'Quiet', + 'fundus' => $elevated ? 'Cup:disc elevated OD; review for glaucoma' : 'Healthy discs', + 'findings' => $elevated + ? 'Elevated IOP with reduced acuity — urgent glaucoma work-up.' + : 'Normal examination; refraction updated.', + ], + $ownerRef, + $ownerRef, + 'exam', + ); + + if ($elevated) { + $clinical->upsert( + $organization, + $visit, + 'ophthalmology', + 'eye_plan', + [ + 'diagnosis' => 'Suspected primary open-angle glaucoma', + 'laterality' => 'OU', + 'plan' => 'Start topical IOP-lowering drops; arrange OCT and visual fields.', + 'medications' => 'Latanoprost 0.005% OU nocte', + 'procedure_planned' => false, + 'follow_up' => '2 weeks', + 'advice' => 'Return sooner if vision worsens or severe eye pain.', + ], + $ownerRef, + $ownerRef, + 'plan', + ); + } + + if (! $visit->specialty_stage) { + $visit->update(['specialty_stage' => $elevated ? '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/Ophthalmology/OphthalmologyAnalyticsService.php b/app/Services/Care/Ophthalmology/OphthalmologyAnalyticsService.php new file mode 100644 index 0000000..624682c --- /dev/null +++ b/app/Services/Care/Ophthalmology/OphthalmologyAnalyticsService.php @@ -0,0 +1,169 @@ +startOfDay(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); + + $departmentIds = $this->shell->departmentIds($organization, 'ophthalmology', $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'); + + $elevatedIopOpen = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'ophthalmology') + ->where('record_type', 'eye_exam') + ->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds) + ->get() + ->filter(function (SpecialtyClinicalRecord $r) { + $iopOd = (float) ($r->payload['iop_od'] ?? 0); + $iopOs = (float) ($r->payload['iop_os'] ?? 0); + + return $iopOd >= 24 || $iopOs >= 24; + }) + ->count(); + + $planRecords = SpecialtyClinicalRecord::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('module_key', 'ophthalmology') + ->where('record_type', 'eye_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', 'ophthalmology') + ->where('record_type', 'eye_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, 'ophthalmology')) + ->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, + 'elevated_iop_open' => $elevatedIopOpen, + '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/Ophthalmology/OphthalmologyWorkflowService.php b/app/Services/Care/Ophthalmology/OphthalmologyWorkflowService.php new file mode 100644 index 0000000..52ab827 --- /dev/null +++ b/app/Services/Care/Ophthalmology/OphthalmologyWorkflowService.php @@ -0,0 +1,105 @@ + $payload + */ + public function stageFromRefraction(array $payload): string + { + $vaOd = trim((string) ($payload['va_od'] ?? '')); + $vaOs = trim((string) ($payload['va_os'] ?? '')); + + if ($vaOd !== '' || $vaOs !== '') { + return self::STAGE_REFRACTION; + } + + return self::STAGE_HISTORY; + } + + /** + * Suggested stage after an eye exam payload is saved. + * + * @param array $payload + */ + public function stageFromExam(array $payload): string + { + $findings = strtolower((string) ($payload['findings'] ?? '')); + if (str_contains($findings, 'oct') + || str_contains($findings, 'field') + || str_contains($findings, 'imaging') + || str_contains($findings, 'investigate')) { + return self::STAGE_INVESTIGATION; + } + + return self::STAGE_EXAM; + } + + /** + * Suggested stage after a diagnosis/plan payload is saved. + * + * @param array $payload + */ + public function stageFromPlan(array $payload): string + { + if (! empty($payload['procedure_planned'])) { + return self::STAGE_TREATMENT; + } + + return self::STAGE_PLAN; + } + + /** + * Whether a procedure payload should close the visit episode. + * + * @param array $payload + */ + public function shouldCompleteVisit(array $payload): bool + { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + + return str_contains($outcome, 'completed'); + } + + /** + * Default stage progression for action buttons. + * + * @return array + */ + public function stageFlow(): array + { + return [ + self::STAGE_CHECK_IN => ['next' => self::STAGE_HISTORY, 'label' => 'Start history'], + self::STAGE_HISTORY => ['next' => self::STAGE_REFRACTION, 'label' => 'Move to VA / refraction'], + self::STAGE_REFRACTION => ['next' => self::STAGE_EXAM, 'label' => 'Move to examination'], + 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 8d865b5..6efcf09 100644 --- a/app/Services/Care/SpecialtyClinicalRecordService.php +++ b/app/Services/Care/SpecialtyClinicalRecordService.php @@ -249,6 +249,52 @@ class SpecialtyClinicalRecordService } } + if ($moduleKey === 'ophthalmology' && in_array($recordType, ['eye_exam', 'refraction'], true)) { + $iopOd = isset($payload['iop_od']) ? (float) $payload['iop_od'] : null; + $iopOs = isset($payload['iop_os']) ? (float) $payload['iop_os'] : null; + if (($iopOd !== null && $iopOd >= 30) || ($iopOs !== null && $iopOs >= 30)) { + $alerts[] = [ + 'code' => 'eye.critical_iop', + 'severity' => 'critical', + 'message' => 'Critical IOP (≥30 mmHg) — urgent glaucoma / acute review.', + ]; + } elseif (($iopOd !== null && $iopOd >= 24) || ($iopOs !== null && $iopOs >= 24)) { + $alerts[] = [ + 'code' => 'eye.elevated_iop', + 'severity' => 'warning', + 'message' => 'Elevated IOP (≥24 mmHg).', + ]; + } + + foreach (['va_od', 'va_os'] as $vaKey) { + $va = strtolower(trim((string) ($payload[$vaKey] ?? ''))); + if ($va === '') { + continue; + } + if (preg_match('/\b(cf|hm|pl|nlp|counting|hand\s*motion|perception)\b/i', $va) + || preg_match('/^6\/(60|90|120)\b/', $va) + || preg_match('/^20\/(200|400)\b/', $va)) { + $alerts[] = [ + 'code' => 'eye.low_vision', + 'severity' => 'warning', + 'message' => 'Low visual acuity recorded ('.$vaKey.').', + ]; + break; + } + } + } + + if ($moduleKey === 'ophthalmology' && $recordType === 'eye_procedure') { + $outcome = strtolower((string) ($payload['outcome'] ?? '')); + if (str_contains($outcome, 'complication') || str_contains($outcome, 'aborted')) { + $alerts[] = [ + 'code' => 'eye.procedure_concern', + 'severity' => 'warning', + 'message' => 'Procedure outcome needs review (complication or aborted).', + ]; + } + } + return $alerts; } diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php index 191105e..e6124e5 100644 --- a/app/Services/Care/SpecialtyShellService.php +++ b/app/Services/Care/SpecialtyShellService.php @@ -270,7 +270,7 @@ class SpecialtyShellService ) { $code = (string) ($stage['code'] ?? ''); - if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank'], true) && $visitIds->isNotEmpty()) { + if (in_array($moduleKey, ['dentistry', 'emergency', 'blood_bank', 'ophthalmology'], true) && $visitIds->isNotEmpty()) { $count = Visit::owned($ownerRef) ->where('organization_id', $organization->id) ->whereIn('id', $visitIds) @@ -300,6 +300,7 @@ class SpecialtyShellService $clinicalOpen = match ($moduleKey) { 'emergency' => $clinical->countOpenByType($organization, 'emergency', 'triage', $branchScope), 'blood_bank' => $clinical->countOpenByType($organization, 'blood_bank', 'blood_request', $branchScope), + 'ophthalmology' => $clinical->countOpenByType($organization, 'ophthalmology', 'eye_exam', $branchScope), '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 b8c54b8..a358a0c 100644 --- a/app/Services/Care/SpecialtyVisitStageService.php +++ b/app/Services/Care/SpecialtyVisitStageService.php @@ -96,6 +96,14 @@ class SpecialtyVisitStageService : ($stages[1] ?? ($stages[0] ?? null)); } + if ($moduleKey === 'ophthalmology') { + $stages = $this->allowedStages($moduleKey); + + return in_array(\App\Services\Care\Ophthalmology\OphthalmologyWorkflowService::STAGE_HISTORY, $stages, true) + ? \App\Services\Care\Ophthalmology\OphthalmologyWorkflowService::STAGE_HISTORY + : ($stages[1] ?? ($stages[0] ?? null)); + } + $stages = $this->allowedStages($moduleKey); foreach (['chair', 'in_care', 'exam', 'assessment', 'treatment'] as $preferred) { if (in_array($preferred, $stages, true)) { diff --git a/config/care.php b/config/care.php index 6b7ae00..bd1e7ef 100644 --- a/config/care.php +++ b/config/care.php @@ -282,6 +282,7 @@ return [ 'specialty_emergency' => 'Emergency document', 'specialty_blood_bank' => 'Blood bank document', 'specialty_dentistry' => 'Dental document', + 'specialty_ophthalmology' => 'Eye care document', 'other' => 'Other', ], diff --git a/config/care_specialty_clinical.php b/config/care_specialty_clinical.php index 9f95fd6..746083e 100644 --- a/config/care_specialty_clinical.php +++ b/config/care_specialty_clinical.php @@ -26,9 +26,11 @@ return [ 'dentistry' => [ ], 'ophthalmology' => [ - 'exam' => 'eye_exam', 'refraction' => 'refraction', - 'clinical_notes' => 'clinical_note', + 'exam' => 'eye_exam', + 'investigations' => 'eye_investigation', + 'plan' => 'eye_plan', + 'treat' => 'eye_procedure', ], 'physiotherapy' => [ 'assessment' => 'pt_assessment', @@ -184,18 +186,13 @@ return [ 'dentistry' => [ ], 'ophthalmology' => [ - 'eye_exam' => [ - ['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true], + 'refraction' => [ ['name' => 'va_od', 'label' => 'Visual acuity OD', 'type' => 'text', 'required' => true], ['name' => 'va_os', 'label' => 'Visual acuity OS', 'type' => 'text', 'required' => true], - ['name' => 'iop_od', 'label' => 'IOP OD (mmHg)', 'type' => 'number'], - ['name' => 'iop_os', 'label' => 'IOP OS (mmHg)', 'type' => 'number'], - ['name' => 'pupils', 'label' => 'Pupils', 'type' => 'text'], - ['name' => 'anterior_segment', 'label' => 'Anterior segment', 'type' => 'textarea', 'rows' => 2], - ['name' => 'fundus', 'label' => 'Fundus / posterior segment', 'type' => 'textarea', 'rows' => 2], - ['name' => 'findings', 'label' => 'Key findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], - ], - 'refraction' => [ + ['name' => 'va_od_near', 'label' => 'Near VA OD', 'type' => 'text'], + ['name' => 'va_os_near', 'label' => 'Near VA OS', 'type' => 'text'], + ['name' => 'pinhole_od', 'label' => 'Pinhole OD', 'type' => 'text'], + ['name' => 'pinhole_os', 'label' => 'Pinhole OS', 'type' => 'text'], ['name' => 'sphere_od', 'label' => 'Sphere OD', 'type' => 'text'], ['name' => 'cylinder_od', 'label' => 'Cylinder OD', 'type' => 'text'], ['name' => 'axis_od', 'label' => 'Axis OD', 'type' => 'text'], @@ -206,11 +203,45 @@ return [ ['name' => 'pd', 'label' => 'Pupillary distance (mm)', 'type' => 'number'], ['name' => 'notes', 'label' => 'Refraction notes', '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], + 'eye_exam' => [ + ['name' => 'chief_complaint', 'label' => 'Chief complaint', 'type' => 'text', 'required' => true], + ['name' => 'history', 'label' => 'History of present illness', 'type' => 'textarea', 'rows' => 3], + ['name' => 'va_od', 'label' => 'Visual acuity OD', 'type' => 'text'], + ['name' => 'va_os', 'label' => 'Visual acuity OS', 'type' => 'text'], + ['name' => 'iop_od', 'label' => 'IOP OD (mmHg)', 'type' => 'number'], + ['name' => 'iop_os', 'label' => 'IOP OS (mmHg)', 'type' => 'number'], + ['name' => 'pupils', 'label' => 'Pupils', 'type' => 'text'], + ['name' => 'extraocular', 'label' => 'Extraocular movements', 'type' => 'text'], + ['name' => 'anterior_segment', 'label' => 'Anterior segment', 'type' => 'textarea', 'rows' => 2], + ['name' => 'fundus', 'label' => 'Fundus / posterior segment', 'type' => 'textarea', 'rows' => 2], + ['name' => 'findings', 'label' => 'Key findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ], + 'eye_investigation' => [ + ['name' => 'modality', 'label' => 'Modality', 'type' => 'select', 'required' => true, 'options' => ['OCT', 'Visual fields', 'Fundus photo', 'Fluorescein angiography', 'Biometry / A-scan', 'B-scan ultrasound', 'Corneal topography', 'Other']], + ['name' => 'eye', 'label' => 'Eye', 'type' => 'select', 'options' => ['OD', 'OS', 'OU']], + ['name' => 'indication', 'label' => 'Indication', 'type' => 'textarea', 'rows' => 2], + ['name' => 'findings', 'label' => 'Findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'impression', 'label' => 'Impression', 'type' => 'textarea', 'rows' => 2], + ['name' => 'recommendations', 'label' => 'Recommendations', 'type' => 'textarea', 'rows' => 2], + ], + 'eye_plan' => [ + ['name' => 'diagnosis', 'label' => 'Diagnosis', 'type' => 'text', 'required' => true], + ['name' => 'laterality', 'label' => 'Laterality', 'type' => 'select', 'options' => ['OD', 'OS', 'OU', 'N/A']], + ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'required' => true, 'rows' => 3], + ['name' => 'medications', 'label' => 'Medications / drops', '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], + ], + 'eye_procedure' => [ + ['name' => 'procedure', 'label' => 'Procedure', 'type' => 'text', 'required' => true], + ['name' => 'eye', 'label' => 'Eye', 'type' => 'select', 'required' => true, 'options' => ['OD', 'OS', 'OU']], + ['name' => 'indication', 'label' => 'Indication', 'type' => 'textarea', 'rows' => 2], + ['name' => 'anaesthesia', 'label' => 'Anaesthesia', 'type' => 'select', 'options' => ['None', 'Topical', 'Local', 'Other']], + ['name' => 'outcome', 'label' => 'Outcome', 'type' => 'select', 'required' => true, 'options' => ['Completed uneventfully', 'Completed with complications', 'Deferred', 'Aborted']], + ['name' => 'complications', 'label' => 'Complications', 'type' => 'textarea', 'rows' => 2], + ['name' => 'post_op_plan', 'label' => 'Post-procedure plan', 'type' => 'textarea', 'rows' => 2], + ['name' => 'notes', 'label' => 'Notes', 'type' => 'textarea', 'rows' => 2], ], ], 'physiotherapy' => [ diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index 45b601c..7c329d0 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -141,9 +141,13 @@ return [ ], 'ophthalmology' => [ 'stages' => [ - ['code' => 'waiting', 'label' => 'Waiting room', 'queue_point' => 'waiting'], - ['code' => 'exam', 'label' => 'Eye exam', 'queue_point' => 'chair'], - ['code' => 'procedure', 'label' => 'Procedure / treatment', 'queue_point' => 'chair'], + ['code' => 'check_in', 'label' => 'Check-in', 'queue_point' => 'waiting'], + ['code' => 'history', 'label' => 'History / intake', 'queue_point' => 'waiting'], + ['code' => 'refraction', 'label' => 'VA / refraction', 'queue_point' => 'chair'], + ['code' => 'exam', 'label' => 'Examination', 'queue_point' => 'chair'], + ['code' => 'investigation', 'label' => 'Investigations', 'queue_point' => 'chair'], + ['code' => 'plan', 'label' => 'Diagnosis & plan', 'queue_point' => 'chair'], + ['code' => 'treatment', 'label' => 'Procedure / treatment', 'queue_point' => 'chair'], ['code' => 'completed', 'label' => 'Completed', 'queue_point' => null], ], 'services' => [ @@ -151,12 +155,22 @@ return [ ['code' => 'eye.refraction', 'label' => 'Refraction / glasses exam', 'amount_minor' => 4000, 'type' => 'procedure'], ['code' => 'eye.iop', 'label' => 'IOP / pressure check', 'amount_minor' => 3000, 'type' => 'procedure'], ['code' => 'eye.fundus', 'label' => 'Fundus examination', 'amount_minor' => 5000, 'type' => 'procedure'], + ['code' => 'eye.slit_lamp', 'label' => 'Slit-lamp examination', 'amount_minor' => 4500, 'type' => 'procedure'], + ['code' => 'eye.oct', 'label' => 'OCT imaging', 'amount_minor' => 18000, 'type' => 'imaging'], + ['code' => 'eye.visual_fields', 'label' => 'Visual fields', 'amount_minor' => 12000, 'type' => 'imaging'], + ['code' => 'eye.biometry', 'label' => 'Biometry / A-scan', 'amount_minor' => 10000, 'type' => 'imaging'], + ['code' => 'eye.foreign_body', 'label' => 'Foreign body removal', 'amount_minor' => 15000, 'type' => 'procedure'], + ['code' => 'eye.yag', 'label' => 'YAG laser', 'amount_minor' => 25000, 'type' => 'procedure'], + ['code' => 'eye.procedure', 'label' => 'Ocular procedure', 'amount_minor' => 20000, 'type' => 'procedure'], + ['code' => 'eye.cataract_assess', 'label' => 'Cataract assessment', 'amount_minor' => 8000, 'type' => 'consultation'], ], 'workspace_tabs' => [ 'overview' => 'Overview', - 'exam' => 'Eye exam', - 'refraction' => 'Refraction', - 'clinical_notes' => 'Clinical notes', + 'refraction' => 'VA / refraction', + 'exam' => 'Examination', + 'investigations' => 'Investigations', + 'plan' => 'Diagnosis & plan', + 'treat' => 'Treatment', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', diff --git a/resources/views/care/specialty/ophthalmology/print.blade.php b/resources/views/care/specialty/ophthalmology/print.blade.php new file mode 100644 index 0000000..2f17132 --- /dev/null +++ b/resources/views/care/specialty/ophthalmology/print.blade.php @@ -0,0 +1,107 @@ + + + + + Eye care 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') ?? '—' }} +

+ +

Visual acuity / refraction

+ @if ($refraction) +
+
VA OD / OS
{{ $refraction->payload['va_od'] ?? '—' }} / {{ $refraction->payload['va_os'] ?? '—' }}
+
Near VA
{{ $refraction->payload['va_od_near'] ?? '—' }} / {{ $refraction->payload['va_os_near'] ?? '—' }}
+
OD Rx
+
+ Sph {{ $refraction->payload['sphere_od'] ?? '—' }} + Cyl {{ $refraction->payload['cylinder_od'] ?? '—' }} + Axis {{ $refraction->payload['axis_od'] ?? '—' }} +
+
OS Rx
+
+ Sph {{ $refraction->payload['sphere_os'] ?? '—' }} + Cyl {{ $refraction->payload['cylinder_os'] ?? '—' }} + Axis {{ $refraction->payload['axis_os'] ?? '—' }} +
+
Add / PD
{{ $refraction->payload['add'] ?? '—' }} / {{ $refraction->payload['pd'] ?? '—' }}
+
Notes
{{ $refraction->payload['notes'] ?? '—' }}
+
+ @else +

No refraction recorded.

+ @endif + +

Examination

+ @if ($exam) +
+
Chief complaint
{{ $exam->payload['chief_complaint'] ?? '—' }}
+
History
{{ $exam->payload['history'] ?? '—' }}
+
IOP OD / OS
{{ $exam->payload['iop_od'] ?? '—' }} / {{ $exam->payload['iop_os'] ?? '—' }}
+
Pupils
{{ $exam->payload['pupils'] ?? '—' }}
+
Anterior segment
{{ $exam->payload['anterior_segment'] ?? '—' }}
+
Fundus
{{ $exam->payload['fundus'] ?? '—' }}
+
Findings
{{ $exam->payload['findings'] ?? '—' }}
+
+ @else +

No examination recorded.

+ @endif + +

Investigations

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

No investigation recorded.

+ @endif + +

Diagnosis & plan

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

No plan recorded.

+ @endif + +

Procedure / treatment

+ @if ($procedure) +
+
Procedure
{{ $procedure->payload['procedure'] ?? '—' }}
+
Eye
{{ $procedure->payload['eye'] ?? '—' }}
+
Outcome
{{ $procedure->payload['outcome'] ?? '—' }}
+
Post-op plan
{{ $procedure->payload['post_op_plan'] ?? '—' }}
+
Notes
{{ $procedure->payload['notes'] ?? '—' }}
+
+ @else +

No procedure recorded.

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

Eye care reports

+

Arrivals, elevated IOP, diagnoses, procedures, length of stay, and eye service revenue.

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

Arrivals today

+

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

+
+
+

Completed today

+

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

+
+
+

Elevated IOP open

+

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

+
+
+

Avg LOS (range)

+

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

+
+
+ +
+
+

Diagnoses

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

Procedures

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

Eye care service revenue

+
    + @forelse ($report['revenue_by_service'] as $row) +
  • + {{ $row->description }} ×{{ $row->total }} + {{ $currency }} {{ number_format($row->amount_minor / 100, 2) }} +
  • + @empty +
  • No billed eye care services in range.
  • + @endforelse +
+
+
+
diff --git a/resources/views/care/specialty/ophthalmology/workspace-overview.blade.php b/resources/views/care/specialty/ophthalmology/workspace-overview.blade.php new file mode 100644 index 0000000..65d13ce --- /dev/null +++ b/resources/views/care/specialty/ophthalmology/workspace-overview.blade.php @@ -0,0 +1,126 @@ +@php + $examPayload = $ophthalmologyExam?->payload ?? []; + $refractionPayload = $ophthalmologyRefraction?->payload ?? []; + $planPayload = $ophthalmologyPlan?->payload ?? []; + $currentStage = $workspaceVisit->specialty_stage; + $stageFlow = $ophthalmologyStageFlow ?? []; + $stageLabels = collect($stages ?? [])->pluck('label', 'code'); + $canManage = $canAdvanceStage ?? $canConsult ?? false; + $vaOd = $refractionPayload['va_od'] ?? ($examPayload['va_od'] ?? null); + $vaOs = $refractionPayload['va_os'] ?? ($examPayload['va_os'] ?? null); +@endphp + +
+
+
+

Eye care overview

+

Visual acuity, IOP, diagnosis, and visit stage for this episode.

+
+ +
+ +
+
+

Visual acuity

+

+ OD {{ $vaOd ?? '—' }} · OS {{ $vaOs ?? '—' }} +

+

{{ $examPayload['chief_complaint'] ?? 'No complaint recorded' }}

+
+
+

IOP (mmHg)

+

+ OD {{ $examPayload['iop_od'] ?? '—' }} · OS {{ $examPayload['iop_os'] ?? '—' }} +

+

Pupils {{ $examPayload['pupils'] ?? '—' }}

+
+
+

Diagnosis

+

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

+

{{ $planPayload['follow_up'] ?? ($planPayload['laterality'] ?? '—') }}

+
+
+ +
+

Visit stage

+

+ {{ $currentStage ? ($stageLabels[$currentStage] ?? ucfirst(str_replace('_', ' ', $currentStage))) : 'Not set' }} +

+
+ @foreach (($ophthalmologyStageCodes ?: ['check_in', 'history', 'refraction', 'exam', 'investigation', 'plan', 'treatment', 'completed']) as $stageCode) + @if ($canManage) +
+ @csrf + + +
+ @else + $currentStage === $stageCode, + 'border border-slate-200 bg-white text-slate-400' => $currentStage !== $stageCode, + ]) + @if ($currentStage !== $stageCode) aria-disabled="true" title="View-only access" @endif + > + {{ $stageLabels[$stageCode] ?? ucfirst(str_replace('_', ' ', $stageCode)) }} + + @endif + @endforeach + @if ($canManage && $currentStage && isset($stageFlow[$currentStage]) && $stageFlow[$currentStage]['next'] !== $currentStage) +
+ @csrf + + +
+ @endif +
+
+ +
+
+
Key findings
+
{{ $examPayload['findings'] ?? '—' }}
+
+
+
Plan
+
{{ $planPayload['plan'] ?? '—' }}
+
+
+
Queue
+
+ {{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }} + @if ($workspaceVisit->appointment?->queue_ticket_status) + ({{ $workspaceVisit->appointment->queue_ticket_status }}) + @endif +
+
+
+
Checked in
+
{{ $workspaceVisit->checked_in_at?->format('d M Y H:i') ?? '—' }}
+
+
+ + @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

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

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

Procedure / treatment

+

Record ocular procedures. Completing a procedure can close the visit.

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

No procedure recorded yet.

+ @endif +
diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index a738778..ece464f 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -52,6 +52,8 @@ @include('care.specialty.emergency.workspace-'.$activeTab) @elseif ($moduleKey === 'blood_bank' && in_array($activeTab, ['overview', 'issue', 'transfusion'], true)) @include('care.specialty.blood-bank.workspace-'.$activeTab) + @elseif ($moduleKey === 'ophthalmology' && in_array($activeTab, ['overview', 'treat'], true)) + @include('care.specialty.ophthalmology.workspace-'.$activeTab) @elseif ($activeTab === 'billing')

diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index e0f19fc..bc74b89 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -34,6 +34,9 @@ Admin @endif @endif + @if ($moduleKey === 'ophthalmology') + Reports + @endif History @if ($canManageClinical ?? $canManageSpecialty ?? true) Billing diff --git a/routes/web.php b/routes/web.php index 52d76a5..5b23df7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -41,6 +41,7 @@ use App\Http\Controllers\Care\BloodBankAdminController; use App\Http\Controllers\Care\BloodBankWorkspaceController; use App\Http\Controllers\Care\LabAdminController; use App\Http\Controllers\Care\EmergencyWorkspaceController; +use App\Http\Controllers\Care\OphthalmologyWorkspaceController; use App\Http\Controllers\Care\SpecialtyModuleController; use App\Http\Controllers\Care\VisitWorkflowController; use App\Http\Controllers\NotificationController; @@ -123,6 +124,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/specialty/dentistry/reports', [DentistryWorkspaceController::class, 'reports'])->name('care.specialty.dentistry.reports'); Route::get('/specialty/emergency/reports', [EmergencyWorkspaceController::class, 'reports'])->name('care.specialty.emergency.reports'); Route::get('/specialty/blood-bank/reports', [BloodBankWorkspaceController::class, 'reports'])->name('care.specialty.blood-bank.reports'); + Route::get('/specialty/ophthalmology/reports', [OphthalmologyWorkspaceController::class, 'reports'])->name('care.specialty.ophthalmology.reports'); Route::get('/specialty/{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'); @@ -156,6 +158,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/specialty/blood-bank/workspace/{visit}/issue', [BloodBankWorkspaceController::class, 'confirmIssue'])->name('care.specialty.blood-bank.issue'); Route::post('/specialty/blood-bank/workspace/{visit}/transfusion', [BloodBankWorkspaceController::class, 'saveTransfusion'])->name('care.specialty.blood-bank.transfusion'); Route::get('/specialty/blood-bank/workspace/{visit}/print', [BloodBankWorkspaceController::class, 'printSummary'])->name('care.specialty.blood-bank.print'); + Route::post('/specialty/ophthalmology/workspace/{visit}/stage', [OphthalmologyWorkspaceController::class, 'setStage'])->name('care.specialty.ophthalmology.stage'); + Route::post('/specialty/ophthalmology/workspace/{visit}/procedure', [OphthalmologyWorkspaceController::class, 'saveProcedure'])->name('care.specialty.ophthalmology.procedure'); + Route::get('/specialty/ophthalmology/workspace/{visit}/print', [OphthalmologyWorkspaceController::class, 'printSummary'])->name('care.specialty.ophthalmology.print'); Route::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/CareOphthalmologySuiteTest.php b/tests/Feature/CareOphthalmologySuiteTest.php new file mode 100644 index 0000000..63736a9 --- /dev/null +++ b/tests/Feature/CareOphthalmologySuiteTest.php @@ -0,0 +1,231 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'eye-owner', + 'name' => 'Owner', + 'email' => 'eye-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Eye Clinic', + 'slug' => 'eye-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, + 'ophthalmology', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'ophthalmology') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-EYE-SUITE', + 'first_name' => 'Ama', + 'last_name' => 'Mensah', + 'gender' => 'female', + 'date_of_birth' => '1990-05-12', + ]); + + $this->visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + 'specialty_stage' => 'check_in', + ]); + + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'department_id' => $department->id, + 'visit_id' => $this->visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_IN_CONSULTATION, + 'scheduled_at' => now(), + 'waiting_at' => now(), + 'checked_in_at' => now(), + 'started_at' => now(), + ]); + } + + public function test_overview_and_refraction_tabs_render(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'ophthalmology', + 'visit' => $this->visit, + 'tab' => 'overview', + ])) + ->assertOk() + ->assertSee('Eye care overview') + ->assertSee('Visit stage') + ->assertSee('Eye care reports'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'ophthalmology', + 'visit' => $this->visit, + 'tab' => 'refraction', + ])) + ->assertOk() + ->assertSee('Visual acuity OD') + ->assertSee('Sphere OD'); + } + + public function test_exam_sets_stage_and_iop_alerts(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.clinical.save', [ + 'module' => 'ophthalmology', + 'visit' => $this->visit, + ]), [ + 'tab' => 'exam', + 'payload' => [ + 'chief_complaint' => 'Blurry vision', + 'iop_od' => 32, + 'iop_os' => 18, + 'findings' => 'Elevated IOP OD', + 'va_od' => '6/60', + ], + ]) + ->assertRedirect(); + + $this->assertSame('exam', $this->visit->fresh()->specialty_stage); + + $record = SpecialtyClinicalRecord::query() + ->where('visit_id', $this->visit->id) + ->where('record_type', 'eye_exam') + ->firstOrFail(); + + $codes = collect($record->alerts)->pluck('code')->all(); + $this->assertContains('eye.critical_iop', $codes); + $this->assertContains('eye.low_vision', $codes); + } + + public function test_stage_advance_and_procedure_completes_visit(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.ophthalmology.stage', $this->visit), [ + 'stage' => 'treatment', + ]) + ->assertRedirect(); + + $this->assertSame('treatment', $this->visit->fresh()->specialty_stage); + + $this->actingAs($this->owner) + ->post(route('care.specialty.ophthalmology.procedure', $this->visit), [ + 'tab' => 'treat', + 'payload' => [ + 'procedure' => 'Foreign body removal', + 'eye' => 'OD', + 'outcome' => 'Completed uneventfully', + 'post_op_plan' => 'Antibiotic drops 5 days', + ], + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'ophthalmology', + 'visit' => $this->visit, + 'tab' => 'treat', + ])); + + $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' => 'eye_procedure', + 'status' => SpecialtyClinicalRecord::STATUS_COMPLETED, + ]); + } + + public function test_reports_and_print(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.ophthalmology.reports')) + ->assertOk() + ->assertSee('Eye care reports') + ->assertSee('Arrivals today'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.ophthalmology.print', $this->visit)) + ->assertOk() + ->assertSee('Eye care 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', 'ophthalmology')) + ->assertOk() + ->assertDontSee('Eye care overview'); + } +} diff --git a/tests/Feature/CareSpecialtyClinicalTest.php b/tests/Feature/CareSpecialtyClinicalTest.php index f4e89ab..f90e522 100644 --- a/tests/Feature/CareSpecialtyClinicalTest.php +++ b/tests/Feature/CareSpecialtyClinicalTest.php @@ -202,9 +202,11 @@ class CareSpecialtyClinicalTest extends TestCase ->fieldsFor('ophthalmology', 'eye_exam'); $names = collect($fields)->pluck('name')->all(); - $this->assertContains('va_od', $names); + $this->assertContains('iop_od', $names); $this->assertContains('iop_os', $names); - $this->assertNotContains('notes', $names); + $this->assertContains('findings', $names); + $this->assertContains('chief_complaint', $names); + $this->assertNotContains('working_diagnosis', $names); [$visit] = $this->activateWithVisit('ophthalmology', 'ophthalmology'); @@ -215,7 +217,7 @@ class CareSpecialtyClinicalTest extends TestCase 'tab' => 'exam', ])) ->assertOk() - ->assertSee('Visual acuity OD') + ->assertSee('Chief complaint') ->assertSee('IOP OS'); } }