From 0edd653187d33cbef3fee9013bb9304feabb906d Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 18 Jul 2026 16:33:34 +0000 Subject: [PATCH] Ship commercial Dentistry suite with odontogram and treatment plans. Replace placeholder clinical forms with patient-level FDI charting, multi-visit plans, procedure-to-bill linking, imaging, reports, and demo seed data. Co-authored-by: Cursor --- .../Care/DentistryWorkspaceController.php | 319 ++++++++++++++++++ app/Http/Controllers/Care/QueueController.php | 8 +- .../Care/SpecialtyModuleController.php | 81 ++++- app/Models/DentalChart.php | 60 ++++ app/Models/DentalChartEvent.php | 52 +++ app/Models/DentalImage.php | 66 ++++ app/Models/DentalPlanItem.php | 54 +++ app/Models/DentalProcedure.php | 67 ++++ app/Models/DentalTooth.php | 36 ++ app/Models/DentalTreatmentPlan.php | 72 ++++ app/Models/DentalVisitNote.php | 45 +++ app/Services/Care/DemoTenantSeeder.php | 76 +++++ .../Care/Dentistry/DentalAnalyticsService.php | 155 +++++++++ app/Services/Care/Dentistry/DentalCatalog.php | 103 ++++++ .../Care/Dentistry/DentalChartService.php | 170 ++++++++++ .../Care/Dentistry/DentalImagingService.php | 83 +++++ .../Care/Dentistry/DentalProcedureService.php | 133 ++++++++ .../Dentistry/DentalTreatmentPlanService.php | 149 ++++++++ .../Care/Dentistry/DentalVisitNoteService.php | 51 +++ app/Services/Care/SpecialtyModuleService.php | 4 + app/Services/Care/SpecialtyShellService.php | 29 +- config/care_specialty_clinical.php | 16 - config/care_specialty_shell.php | 16 +- ...18_160000_create_care_dentistry_tables.php | 185 ++++++++++ .../care/specialty/dentistry/print.blade.php | 83 +++++ .../specialty/dentistry/reports.blade.php | 72 ++++ .../dentistry/workspace-imaging.blade.php | 77 +++++ .../dentistry/workspace-notes.blade.php | 25 ++ .../dentistry/workspace-odontogram.blade.php | 101 ++++++ .../dentistry/workspace-overview.blade.php | 73 ++++ .../dentistry/workspace-plan.blade.php | 92 +++++ .../dentistry/workspace-treat.blade.php | 97 ++++++ .../specialty/partials/actions-menu.blade.php | 8 +- .../specialty/sections/workspace.blade.php | 2 + .../views/care/specialty/shell.blade.php | 3 + routes/web.php | 9 + tests/Feature/CareDentistrySuiteTest.php | 269 +++++++++++++++ 37 files changed, 2906 insertions(+), 35 deletions(-) create mode 100644 app/Http/Controllers/Care/DentistryWorkspaceController.php create mode 100644 app/Models/DentalChart.php create mode 100644 app/Models/DentalChartEvent.php create mode 100644 app/Models/DentalImage.php create mode 100644 app/Models/DentalPlanItem.php create mode 100644 app/Models/DentalProcedure.php create mode 100644 app/Models/DentalTooth.php create mode 100644 app/Models/DentalTreatmentPlan.php create mode 100644 app/Models/DentalVisitNote.php create mode 100644 app/Services/Care/Dentistry/DentalAnalyticsService.php create mode 100644 app/Services/Care/Dentistry/DentalCatalog.php create mode 100644 app/Services/Care/Dentistry/DentalChartService.php create mode 100644 app/Services/Care/Dentistry/DentalImagingService.php create mode 100644 app/Services/Care/Dentistry/DentalProcedureService.php create mode 100644 app/Services/Care/Dentistry/DentalTreatmentPlanService.php create mode 100644 app/Services/Care/Dentistry/DentalVisitNoteService.php create mode 100644 database/migrations/2026_07_18_160000_create_care_dentistry_tables.php create mode 100644 resources/views/care/specialty/dentistry/print.blade.php create mode 100644 resources/views/care/specialty/dentistry/reports.blade.php create mode 100644 resources/views/care/specialty/dentistry/workspace-imaging.blade.php create mode 100644 resources/views/care/specialty/dentistry/workspace-notes.blade.php create mode 100644 resources/views/care/specialty/dentistry/workspace-odontogram.blade.php create mode 100644 resources/views/care/specialty/dentistry/workspace-overview.blade.php create mode 100644 resources/views/care/specialty/dentistry/workspace-plan.blade.php create mode 100644 resources/views/care/specialty/dentistry/workspace-treat.blade.php create mode 100644 tests/Feature/CareDentistrySuiteTest.php diff --git a/app/Http/Controllers/Care/DentistryWorkspaceController.php b/app/Http/Controllers/Care/DentistryWorkspaceController.php new file mode 100644 index 0000000..c1f1ddb --- /dev/null +++ b/app/Http/Controllers/Care/DentistryWorkspaceController.php @@ -0,0 +1,319 @@ +organization($request); + abort_unless($modules->memberCanAccess($organization, $this->member($request), 'dentistry'), 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 updateTooth( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + DentalChartService $charts, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'tooth_code' => ['required', 'string', 'max:8'], + 'status' => ['required', 'string', 'in:present,missing,extracted,implant'], + 'surfaces' => ['nullable', 'array'], + 'surfaces.*' => ['string', 'max:2'], + 'conditions' => ['nullable', 'array'], + 'conditions.*' => ['string', 'max:32'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + $visit->loadMissing('patient'); + $chart = $charts->chartForPatient($organization, $visit->patient, $owner); + + try { + $charts->updateTooth( + $chart, + $validated['tooth_code'], + $validated, + $owner, + $this->actorRef($request), + $visit, + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'odontogram']) + ->with('success', 'Tooth '.$validated['tooth_code'].' updated.'); + } + + public function addPlanItem( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + DentalTreatmentPlanService $plans, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'procedure_code' => ['required', 'string', 'max:64'], + 'tooth_code' => ['nullable', 'string', 'max:8'], + 'surfaces' => ['nullable', 'array'], + 'surfaces.*' => ['string', 'max:2'], + 'priority' => ['nullable', 'integer', 'min:1', 'max:100'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + $visit->loadMissing('patient'); + $plan = $plans->ensurePlan($organization, $visit->patient, $owner, $visit, $this->actorRef($request)); + + try { + $plans->addItem($plan, $organization, $validated, $owner, $this->actorRef($request)); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'plan']) + ->with('success', 'Added to treatment plan.'); + } + + public function acceptPlan( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + DentalTreatmentPlanService $plans, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + $visit->loadMissing('patient'); + $plan = $plans->activePlanForPatient($organization, $visit->patient, $owner); + if (! $plan) { + return back()->with('error', 'No open treatment plan.'); + } + + $plans->accept($plan, $owner, $this->actorRef($request)); + + return redirect() + ->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'plan']) + ->with('success', 'Treatment plan accepted.'); + } + + public function completeProcedure( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + DentalProcedureService $procedures, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'procedure_code' => ['required', 'string', 'max:64'], + 'tooth_code' => ['nullable', 'string', 'max:8'], + 'surfaces' => ['nullable', 'array'], + 'surfaces.*' => ['string', 'max:2'], + 'anesthesia' => ['nullable', 'string', 'max:32'], + 'notes' => ['nullable', 'string', 'max:2000'], + 'plan_item_id' => ['nullable', 'integer'], + 'bill' => ['nullable', 'boolean'], + ]); + + try { + $procedures->complete( + $this->organization($request), + $visit, + [ + ...$validated, + 'bill' => $request->boolean('bill', true), + ], + $this->ownerRef($request), + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'treat']) + ->with('success', 'Procedure recorded and billed.'); + } + + public function saveNotes( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + DentalVisitNoteService $notes, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'chief_complaint' => ['nullable', 'string', 'max:5000'], + 'soft_tissue' => ['nullable', 'string', 'max:5000'], + 'occlusion' => ['nullable', 'string', 'max:5000'], + 'notes' => ['nullable', 'string', 'max:5000'], + ]); + + $notes->upsert( + $this->organization($request), + $visit, + $validated, + $this->ownerRef($request), + $this->actorRef($request), + ); + + return redirect() + ->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'notes']) + ->with('success', 'Visit notes saved.'); + } + + public function uploadImage( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + DentalImagingService $imaging, + DentalProcedureService $procedures, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'attachment' => ['required', 'file', 'max:10240', 'mimes:pdf,jpeg,jpg,png,webp'], + 'modality' => ['required', 'string', 'in:pa,bw,opg,photo'], + 'tooth_codes' => ['nullable', 'array'], + 'tooth_codes.*' => ['string', 'max:8'], + 'caption' => ['nullable', 'string', 'max:255'], + 'add_fee' => ['nullable', 'boolean'], + ]); + + try { + $imaging->upload( + $this->organization($request), + $visit, + $validated['attachment'], + $validated, + $this->ownerRef($request), + $this->actorRef($request), + ); + + if ($request->boolean('add_fee')) { + $feeCode = match ($validated['modality']) { + 'pa' => 'den.xray_pa', + 'bw' => 'den.xray_bw', + 'opg' => 'den.xray_opg', + default => 'den.photo', + }; + $procedures->complete( + $this->organization($request), + $visit, + [ + 'procedure_code' => $feeCode, + 'tooth_code' => ($validated['tooth_codes'][0] ?? null), + 'bill' => true, + 'notes' => 'Imaging fee', + ], + $this->ownerRef($request), + $this->actorRef($request), + ); + } + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'imaging']) + ->with('success', 'Image uploaded.'); + } + + public function reports( + Request $request, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + DentalAnalyticsService $analytics, + ): View { + $this->assertDentistryAccess($request, $modules); + $organization = $this->organization($request); + $member = $this->member($request); + $branchScope = app(\App\Services\Care\OrganizationResolver::class)->branchScope($member); + + return view('care.specialty.dentistry.reports', [ + 'moduleKey' => 'dentistry', + 'definition' => $modules->definition('dentistry'), + 'report' => $analytics->report($organization, $this->ownerRef($request), $branchScope), + 'modalities' => DentalCatalog::modalities(), + 'currency' => config('care.billing.currency', 'GHS'), + 'shellNav' => $shell->navItems('dentistry'), + ]); + } + + public function printChart( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + DentalChartService $charts, + DentalTreatmentPlanService $plans, + ): StreamedResponse|View { + $this->authorizeAbility($request, 'consultations.view'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + $visit->loadMissing('patient'); + $chart = $charts->chartForPatient($organization, $visit->patient, $owner); + $plan = $plans->activePlanForPatient($organization, $visit->patient, $owner); + + return view('care.specialty.dentistry.print', [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'chart' => $chart, + 'teethMap' => $charts->teethMap($chart), + 'plan' => $plan, + 'rows' => DentalCatalog::odontogramRows(), + 'conditions' => DentalCatalog::conditions(), + ]); + } +} diff --git a/app/Http/Controllers/Care/QueueController.php b/app/Http/Controllers/Care/QueueController.php index bf5b04a..12e236b 100644 --- a/app/Http/Controllers/Care/QueueController.php +++ b/app/Http/Controllers/Care/QueueController.php @@ -283,9 +283,11 @@ class QueueController extends Controller if ($specialtyContext !== CareQueueContexts::CONSULTATION && $appointment->visit) { $clinical = app(\App\Services\Care\SpecialtyClinicalRecordService::class); $shell = app(\App\Services\Care\SpecialtyShellService::class); - $preferredTab = collect(array_keys($shell->workspaceTabs($specialtyContext))) - ->first(fn (string $tab) => $clinical->recordTypeForTab($specialtyContext, $tab) !== null) - ?? 'clinical_notes'; + $preferredTab = $specialtyContext === 'dentistry' + ? 'odontogram' + : (collect(array_keys($shell->workspaceTabs($specialtyContext))) + ->first(fn (string $tab) => $clinical->recordTypeForTab($specialtyContext, $tab) !== null) + ?? 'clinical_notes'); return redirect() ->route('care.specialty.workspace', [ diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 14c37dc..d527294 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -155,9 +155,11 @@ class SpecialtyModuleController extends Controller if ($openConsultation && $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED) { $clinical = app(SpecialtyClinicalRecordService::class); $shellTabs = app(SpecialtyShellService::class)->workspaceTabs($module); - $preferredTab = collect(array_keys($shellTabs)) - ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) - ?? 'clinical_notes'; + $preferredTab = $module === 'dentistry' + ? 'odontogram' + : (collect(array_keys($shellTabs)) + ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) + ?? 'clinical_notes'); return redirect() ->route('care.specialty.workspace', [ @@ -196,9 +198,11 @@ class SpecialtyModuleController extends Controller $visit = $visit->fresh() ?? $appointment->visit; $clinical = app(SpecialtyClinicalRecordService::class); $shellTabs = app(SpecialtyShellService::class)->workspaceTabs($module); - $preferredTab = collect(array_keys($shellTabs)) - ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) - ?? 'clinical_notes'; + $preferredTab = $module === 'dentistry' + ? 'odontogram' + : (collect(array_keys($shellTabs)) + ->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null) + ?? 'clinical_notes'); return redirect() ->route('care.specialty.workspace', [ @@ -503,6 +507,18 @@ class SpecialtyModuleController extends Controller $clinicalFields = []; $clinicalAlerts = []; $visitDocuments = collect(); + $dentalChart = null; + $dentalTeethMap = []; + $dentalPlan = null; + $dentalProcedures = collect(); + $dentalImages = collect(); + $dentalVisitNote = null; + $dentalCatalog = []; + $dentalRows = []; + $dentalConditions = []; + $dentalStatuses = []; + $dentalSurfaces = []; + $dentalModalities = []; $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); $clinical = app(SpecialtyClinicalRecordService::class); @@ -553,6 +569,47 @@ class SpecialtyModuleController extends Controller $clinicalFields = $clinical->fieldsFor($module, $recordType); } $clinicalAlerts = $clinical->alertsForVisit($workspaceVisit, $module); + + if ($module === 'dentistry' && $workspaceVisit->patient) { + $chartService = app(\App\Services\Care\Dentistry\DentalChartService::class); + $planService = app(\App\Services\Care\Dentistry\DentalTreatmentPlanService::class); + $analytics = app(\App\Services\Care\Dentistry\DentalAnalyticsService::class); + + $dentalChart = $chartService->chartForPatient($organization, $workspaceVisit->patient, $owner); + $dentalTeethMap = $chartService->teethMap($dentalChart); + $dentalPlan = $planService->activePlanForPatient($organization, $workspaceVisit->patient, $owner); + $dentalProcedures = \App\Models\DentalProcedure::owned($owner) + ->where('visit_id', $workspaceVisit->id) + ->orderByDesc('id') + ->get(); + $dentalImages = \App\Models\DentalImage::owned($owner) + ->where(function ($q) use ($workspaceVisit) { + $q->where('visit_id', $workspaceVisit->id) + ->orWhere(function ($q2) use ($workspaceVisit) { + $q2->where('patient_id', $workspaceVisit->patient_id) + ->whereNull('visit_id'); + }); + }) + ->with('attachment') + ->orderByDesc('id') + ->limit(40) + ->get(); + $dentalVisitNote = \App\Models\DentalVisitNote::owned($owner) + ->where('visit_id', $workspaceVisit->id) + ->first(); + $dentalCatalog = $shell->provisionedServices($organization, 'dentistry'); + $dentalRows = \App\Services\Care\Dentistry\DentalCatalog::odontogramRows(); + $dentalConditions = \App\Services\Care\Dentistry\DentalCatalog::conditions(); + $dentalStatuses = \App\Services\Care\Dentistry\DentalCatalog::toothStatuses(); + $dentalSurfaces = \App\Services\Care\Dentistry\DentalCatalog::surfaces(); + $dentalModalities = \App\Services\Care\Dentistry\DentalCatalog::modalities(); + + $clinicalAlerts = array_merge( + $clinicalAlerts, + $analytics->alertsForPatient($organization, $workspaceVisit->patient, $owner), + ); + } + if ($patientHeader !== null) { $patientHeader['clinical_alerts'] = $clinicalAlerts; } @@ -593,6 +650,18 @@ class SpecialtyModuleController extends Controller 'clinicalFields' => $clinicalFields, 'clinicalAlerts' => $clinicalAlerts, 'visitDocuments' => $visitDocuments, + 'dentalChart' => $dentalChart, + 'dentalTeethMap' => $dentalTeethMap, + 'dentalPlan' => $dentalPlan, + 'dentalProcedures' => $dentalProcedures, + 'dentalImages' => $dentalImages, + 'dentalVisitNote' => $dentalVisitNote, + 'dentalCatalog' => $dentalCatalog, + 'dentalRows' => $dentalRows, + 'dentalConditions' => $dentalConditions, + 'dentalStatuses' => $dentalStatuses, + 'dentalSurfaces' => $dentalSurfaces, + 'dentalModalities' => $dentalModalities, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'branchLabel' => $branchLabel, diff --git a/app/Models/DentalChart.php b/app/Models/DentalChart.php new file mode 100644 index 0000000..fcd98af --- /dev/null +++ b/app/Models/DentalChart.php @@ -0,0 +1,60 @@ + 'datetime']; + } + + protected static function booted(): void + { + static::creating(function (DentalChart $chart) { + if (! $chart->uuid) { + $chart->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function teeth(): HasMany + { + return $this->hasMany(DentalTooth::class, 'dental_chart_id'); + } + + public function events(): HasMany + { + return $this->hasMany(DentalChartEvent::class, 'dental_chart_id'); + } +} diff --git a/app/Models/DentalChartEvent.php b/app/Models/DentalChartEvent.php new file mode 100644 index 0000000..cd7c2bd --- /dev/null +++ b/app/Models/DentalChartEvent.php @@ -0,0 +1,52 @@ + 'array', + 'recorded_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (DentalChartEvent $event) { + if (! $event->uuid) { + $event->uuid = (string) Str::uuid(); + } + }); + } + + public function chart(): BelongsTo + { + return $this->belongsTo(DentalChart::class, 'dental_chart_id'); + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } +} diff --git a/app/Models/DentalImage.php b/app/Models/DentalImage.php new file mode 100644 index 0000000..c7da420 --- /dev/null +++ b/app/Models/DentalImage.php @@ -0,0 +1,66 @@ + 'array', + 'captured_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (DentalImage $image) { + if (! $image->uuid) { + $image->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function attachment(): BelongsTo + { + return $this->belongsTo(PatientAttachment::class, 'patient_attachment_id'); + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } +} diff --git a/app/Models/DentalPlanItem.php b/app/Models/DentalPlanItem.php new file mode 100644 index 0000000..12e222f --- /dev/null +++ b/app/Models/DentalPlanItem.php @@ -0,0 +1,54 @@ + 'array']; + } + + protected static function booted(): void + { + static::creating(function (DentalPlanItem $item) { + if (! $item->uuid) { + $item->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function plan(): BelongsTo + { + return $this->belongsTo(DentalTreatmentPlan::class, 'treatment_plan_id'); + } + + public function billLineItem(): BelongsTo + { + return $this->belongsTo(BillLineItem::class, 'bill_line_item_id'); + } +} diff --git a/app/Models/DentalProcedure.php b/app/Models/DentalProcedure.php new file mode 100644 index 0000000..9ac955a --- /dev/null +++ b/app/Models/DentalProcedure.php @@ -0,0 +1,67 @@ + 'array', + 'performed_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (DentalProcedure $procedure) { + if (! $procedure->uuid) { + $procedure->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function planItem(): BelongsTo + { + return $this->belongsTo(DentalPlanItem::class, 'plan_item_id'); + } + + public function billLineItem(): BelongsTo + { + return $this->belongsTo(BillLineItem::class, 'bill_line_item_id'); + } +} diff --git a/app/Models/DentalTooth.php b/app/Models/DentalTooth.php new file mode 100644 index 0000000..c268e7b --- /dev/null +++ b/app/Models/DentalTooth.php @@ -0,0 +1,36 @@ + 'array', + 'conditions' => 'array', + ]; + } + + public function chart(): BelongsTo + { + return $this->belongsTo(DentalChart::class, 'dental_chart_id'); + } +} diff --git a/app/Models/DentalTreatmentPlan.php b/app/Models/DentalTreatmentPlan.php new file mode 100644 index 0000000..dcaac53 --- /dev/null +++ b/app/Models/DentalTreatmentPlan.php @@ -0,0 +1,72 @@ + 'datetime', + 'completed_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (DentalTreatmentPlan $plan) { + if (! $plan->uuid) { + $plan->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function items(): HasMany + { + return $this->hasMany(DentalPlanItem::class, 'treatment_plan_id')->orderBy('priority')->orderBy('id'); + } +} diff --git a/app/Models/DentalVisitNote.php b/app/Models/DentalVisitNote.php new file mode 100644 index 0000000..ffe0481 --- /dev/null +++ b/app/Models/DentalVisitNote.php @@ -0,0 +1,45 @@ + 'datetime']; + } + + protected static function booted(): void + { + static::creating(function (DentalVisitNote $note) { + if (! $note->uuid) { + $note->uuid = (string) Str::uuid(); + } + }); + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } +} diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 1c29198..92cb425 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -949,9 +949,85 @@ class DemoTenantSeeder } } + $this->seedDentistryClinicalDemo($organization, $ownerRef); + return $count; } + private function seedDentistryClinicalDemo(Organization $organization, string $ownerRef): void + { + $departmentIds = Department::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('type', 'dental') + ->where('is_active', true) + ->pluck('id'); + + if ($departmentIds->isEmpty()) { + return; + } + + $appointment = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('department_id', $departmentIds) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN, Appointment::STATUS_IN_CONSULTATION]) + ->whereNotNull('visit_id') + ->with(['patient', 'visit']) + ->latest('id') + ->first(); + + if (! $appointment?->patient || ! $appointment->visit) { + return; + } + + $charts = app(\App\Services\Care\Dentistry\DentalChartService::class); + $plans = app(\App\Services\Care\Dentistry\DentalTreatmentPlanService::class); + $procedures = app(\App\Services\Care\Dentistry\DentalProcedureService::class); + + $chart = $charts->chartForPatient($organization, $appointment->patient, $ownerRef); + $charts->updateTooth($chart, '16', [ + 'status' => 'present', + 'surfaces' => ['O'], + 'conditions' => ['caries'], + 'notes' => 'Demo occlusal caries', + ], $ownerRef, $ownerRef, $appointment->visit); + + $plan = $plans->ensurePlan($organization, $appointment->patient, $ownerRef, $appointment->visit, $ownerRef); + if ($plan->items()->count() === 0) { + $plans->addItem($plan, $organization, [ + 'procedure_code' => 'den.fill', + 'tooth_code' => '16', + 'surfaces' => ['O'], + 'priority' => 10, + 'notes' => 'Demo filling', + ], $ownerRef, $ownerRef); + $plans->accept($plan->fresh('items'), $ownerRef, $ownerRef); + } + + $completedPatient = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('department_id', $departmentIds) + ->where('status', Appointment::STATUS_COMPLETED) + ->whereNotNull('visit_id') + ->with('visit') + ->latest('id') + ->first(); + + if ($completedPatient?->visit && ! \App\Models\DentalProcedure::owned($ownerRef)->where('visit_id', $completedPatient->visit_id)->exists()) { + try { + $procedures->complete($organization, $completedPatient->visit, [ + 'procedure_code' => 'den.fill', + 'tooth_code' => '26', + 'surfaces' => ['O'], + 'anesthesia' => 'Local', + 'bill' => true, + 'notes' => 'Demo completed fill', + ], $ownerRef, $ownerRef); + } catch (\Throwable) { + // Billing may be constrained in partial seeds — chart/plan still valuable. + } + } + } + /** * @param list $branches * @param array> $departments diff --git a/app/Services/Care/Dentistry/DentalAnalyticsService.php b/app/Services/Care/Dentistry/DentalAnalyticsService.php new file mode 100644 index 0000000..f202d76 --- /dev/null +++ b/app/Services/Care/Dentistry/DentalAnalyticsService.php @@ -0,0 +1,155 @@ +startOfDay(); + $monthStart = now()->startOfMonth(); + + $procBase = DentalProcedure::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('status', DentalProcedure::STATUS_COMPLETED); + + $proceduresToday = (clone $procBase) + ->where('performed_at', '>=', $todayStart) + ->selectRaw('procedure_code, label, count(*) as total, sum(amount_minor) as amount_minor') + ->groupBy('procedure_code', 'label') + ->orderByDesc('total') + ->get(); + + $proceduresMonth = (clone $procBase) + ->where('performed_at', '>=', $monthStart) + ->selectRaw('procedure_code, label, count(*) as total, sum(amount_minor) as amount_minor') + ->groupBy('procedure_code', 'label') + ->orderByDesc('total') + ->get(); + + $revenueByCode = BillLineItem::query() + ->where('owner_ref', $ownerRef) + ->where('source_type', 'dental_procedure') + ->where('created_at', '>=', $monthStart) + ->selectRaw('description, sum(total_minor) as amount_minor, count(*) as total') + ->groupBy('description') + ->orderByDesc('amount_minor') + ->limit(20) + ->get(); + + $unfinished = DentalPlanItem::query() + ->whereHas('plan', function ($q) use ($organization, $ownerRef) { + $q->owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('status', [ + DentalTreatmentPlan::STATUS_ACCEPTED, + DentalTreatmentPlan::STATUS_IN_PROGRESS, + ]); + }) + ->whereIn('status', [DentalPlanItem::STATUS_ACCEPTED, DentalPlanItem::STATUS_PROPOSED]) + ->count(); + + $chairQuery = Appointment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('status', Appointment::STATUS_COMPLETED) + ->whereNotNull('started_at') + ->whereNotNull('completed_at') + ->where('completed_at', '>=', $monthStart) + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)); + + $avgChair = null; + $samples = (clone $chairQuery)->limit(200)->get(['started_at', 'completed_at']); + if ($samples->isNotEmpty()) { + $avgChair = round($samples->avg(fn ($a) => $a->started_at->diffInMinutes($a->completed_at)), 1); + } + + $imaging = DentalImage::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('captured_at', '>=', $monthStart) + ->selectRaw('modality, count(*) as total') + ->groupBy('modality') + ->orderByDesc('total') + ->get(); + + return [ + 'procedures_today' => $proceduresToday, + 'procedures_month' => $proceduresMonth, + 'revenue_by_code' => $revenueByCode, + 'unfinished_items' => $unfinished, + 'avg_chair_minutes' => $avgChair, + 'imaging_by_modality' => $imaging, + ]; + } + + /** + * @return list + */ + public function alertsForPatient(Organization $organization, Patient $patient, string $ownerRef): array + { + $alerts = []; + + $plan = DentalTreatmentPlan::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('patient_id', $patient->id) + ->whereIn('status', [ + DentalTreatmentPlan::STATUS_ACCEPTED, + DentalTreatmentPlan::STATUS_IN_PROGRESS, + ]) + ->with('items') + ->latest('id') + ->first(); + + if ($plan && $plan->accepted_at && $plan->accepted_at->lt(now()->subDays(30))) { + $open = $plan->items->whereIn('status', [ + DentalPlanItem::STATUS_ACCEPTED, + DentalPlanItem::STATUS_PROPOSED, + ])->count(); + if ($open > 0) { + $alerts[] = [ + 'code' => 'dental.plan.stale', + 'severity' => 'warning', + 'message' => "Accepted treatment plan has {$open} unfinished item(s) older than 30 days.", + ]; + } + } + + if ($plan) { + $high = $plan->items + ->where('priority', '<=', 20) + ->whereIn('status', [DentalPlanItem::STATUS_ACCEPTED, DentalPlanItem::STATUS_PROPOSED]); + foreach ($high->take(3) as $item) { + $tooth = $item->tooth_code ? " tooth {$item->tooth_code}" : ''; + $alerts[] = [ + 'code' => 'dental.plan.high_priority', + 'severity' => 'warning', + 'message' => "High-priority planned: {$item->label}{$tooth}.", + ]; + } + } + + return $alerts; + } +} diff --git a/app/Services/Care/Dentistry/DentalCatalog.php b/app/Services/Care/Dentistry/DentalCatalog.php new file mode 100644 index 0000000..0fb95a3 --- /dev/null +++ b/app/Services/Care/Dentistry/DentalCatalog.php @@ -0,0 +1,103 @@ + */ + public static function adultToothCodes(): array + { + $codes = []; + foreach ([18, 17, 16, 15, 14, 13, 12, 11, 21, 22, 23, 24, 25, 26, 27, 28] as $n) { + $codes[] = (string) $n; + } + foreach ([48, 47, 46, 45, 44, 43, 42, 41, 31, 32, 33, 34, 35, 36, 37, 38] as $n) { + $codes[] = (string) $n; + } + + return $codes; + } + + /** + * @return array{upper: list, lower: list} + */ + public static function odontogramRows(): array + { + return [ + 'upper' => ['18', '17', '16', '15', '14', '13', '12', '11', '21', '22', '23', '24', '25', '26', '27', '28'], + 'lower' => ['48', '47', '46', '45', '44', '43', '42', '41', '31', '32', '33', '34', '35', '36', '37', '38'], + ]; + } + + /** @return list */ + public static function surfaces(): array + { + return ['M', 'O', 'D', 'B', 'L', 'I']; + } + + /** @return array */ + public static function conditions(): array + { + return [ + 'caries' => 'Caries', + 'filled' => 'Filled', + 'crown' => 'Crown', + 'root_canal' => 'Root canal', + 'fracture' => 'Fracture', + 'watch' => 'Watch', + 'missing' => 'Missing', + 'implant' => 'Implant', + ]; + } + + /** @return array */ + public static function toothStatuses(): array + { + return [ + 'present' => 'Present', + 'missing' => 'Missing', + 'extracted' => 'Extracted', + 'implant' => 'Implant', + ]; + } + + /** @return array */ + public static function modalities(): array + { + return [ + 'pa' => 'Periapical (PA)', + 'bw' => 'Bitewing (BW)', + 'opg' => 'OPG / panoramic', + 'photo' => 'Clinical photo', + ]; + } + + /** @return array */ + public static function defaultServices(): array + { + return [ + 'den.consult' => ['label' => 'Dental consultation', 'amount_minor' => 5000, 'type' => 'consultation'], + 'den.exam' => ['label' => 'Dental examination', 'amount_minor' => 4000, 'type' => 'consultation'], + 'den.cleaning' => ['label' => 'Scaling / polishing', 'amount_minor' => 8000, 'type' => 'procedure'], + 'den.fill' => ['label' => 'Filling', 'amount_minor' => 12000, 'type' => 'procedure'], + 'den.extraction' => ['label' => 'Extraction', 'amount_minor' => 15000, 'type' => 'procedure'], + 'den.rct' => ['label' => 'Root canal treatment', 'amount_minor' => 35000, 'type' => 'procedure'], + 'den.crown' => ['label' => 'Crown', 'amount_minor' => 45000, 'type' => 'procedure'], + 'den.bridge' => ['label' => 'Bridge unit', 'amount_minor' => 40000, 'type' => 'procedure'], + 'den.implant_consult' => ['label' => 'Implant consultation', 'amount_minor' => 10000, 'type' => 'consultation'], + 'den.perio' => ['label' => 'Periodontal treatment', 'amount_minor' => 20000, 'type' => 'procedure'], + 'den.xray_pa' => ['label' => 'Periapical X-ray', 'amount_minor' => 3000, 'type' => 'imaging'], + 'den.xray_bw' => ['label' => 'Bitewing X-ray', 'amount_minor' => 3500, 'type' => 'imaging'], + 'den.xray_opg' => ['label' => 'OPG / panoramic X-ray', 'amount_minor' => 8000, 'type' => 'imaging'], + 'den.photo' => ['label' => 'Clinical photography', 'amount_minor' => 2000, 'type' => 'imaging'], + ]; + } + + public static function isValidToothCode(string $code): bool + { + return in_array($code, self::adultToothCodes(), true); + } +} diff --git a/app/Services/Care/Dentistry/DentalChartService.php b/app/Services/Care/Dentistry/DentalChartService.php new file mode 100644 index 0000000..635d0ee --- /dev/null +++ b/app/Services/Care/Dentistry/DentalChartService.php @@ -0,0 +1,170 @@ +where('organization_id', $organization->id) + ->where('patient_id', $patient->id) + ->with('teeth') + ->first(); + + if ($chart) { + return $chart; + } + + return DB::transaction(function () use ($organization, $patient, $ownerRef) { + $chart = DentalChart::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'patient_id' => $patient->id, + 'notation' => 'fdi', + 'charted_at' => now(), + 'charted_by' => $ownerRef, + ]); + + foreach (DentalCatalog::adultToothCodes() as $code) { + DentalTooth::create([ + 'dental_chart_id' => $chart->id, + 'tooth_code' => $code, + 'status' => DentalTooth::STATUS_PRESENT, + 'surfaces' => [], + 'conditions' => [], + ]); + } + + return $chart->fresh('teeth'); + }); + } + + /** + * @param array{status?: string, surfaces?: list, conditions?: list, notes?: ?string} $data + */ + public function updateTooth( + DentalChart $chart, + string $toothCode, + array $data, + string $ownerRef, + ?string $actorRef = null, + ?Visit $visit = null, + ): DentalTooth { + if (! DentalCatalog::isValidToothCode($toothCode)) { + throw new \InvalidArgumentException("Invalid FDI tooth code [{$toothCode}]."); + } + + $tooth = $chart->teeth()->where('tooth_code', $toothCode)->first(); + if (! $tooth) { + $tooth = DentalTooth::create([ + 'dental_chart_id' => $chart->id, + 'tooth_code' => $toothCode, + 'status' => DentalTooth::STATUS_PRESENT, + 'surfaces' => [], + 'conditions' => [], + ]); + } + + $before = [ + 'status' => $tooth->status, + 'surfaces' => $tooth->surfaces ?? [], + 'conditions' => $tooth->conditions ?? [], + 'notes' => $tooth->notes, + ]; + + $status = $data['status'] ?? $tooth->status; + if (! array_key_exists($status, DentalCatalog::toothStatuses())) { + throw new \InvalidArgumentException("Invalid tooth status [{$status}]."); + } + + $surfaces = array_values(array_intersect( + array_map('strtoupper', $data['surfaces'] ?? ($tooth->surfaces ?? [])), + DentalCatalog::surfaces(), + )); + + $allowedConditions = array_keys(DentalCatalog::conditions()); + $conditions = array_values(array_intersect( + $data['conditions'] ?? ($tooth->conditions ?? []), + $allowedConditions, + )); + + if (in_array($status, [DentalTooth::STATUS_MISSING, DentalTooth::STATUS_EXTRACTED], true)) { + $surfaces = []; + if (! in_array('missing', $conditions, true)) { + $conditions[] = 'missing'; + } + } + + $tooth->update([ + 'status' => $status, + 'surfaces' => $surfaces, + 'conditions' => $conditions, + 'notes' => $data['notes'] ?? $tooth->notes, + ]); + + $chart->update([ + 'charted_at' => now(), + 'charted_by' => $actorRef ?? $ownerRef, + ]); + + $after = [ + 'status' => $tooth->status, + 'surfaces' => $tooth->surfaces ?? [], + 'conditions' => $tooth->conditions ?? [], + 'notes' => $tooth->notes, + ]; + + DentalChartEvent::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $chart->organization_id, + 'dental_chart_id' => $chart->id, + 'patient_id' => $chart->patient_id, + 'visit_id' => $visit?->id, + 'tooth_code' => $toothCode, + 'event_type' => 'tooth_updated', + 'payload' => ['before' => $before, 'after' => $after], + 'recorded_by' => $actorRef ?? $ownerRef, + 'recorded_at' => now(), + ]); + + AuditLogger::record( + $ownerRef, + 'dental.chart.tooth_updated', + $chart->organization_id, + $actorRef, + DentalTooth::class, + $tooth->id, + ['tooth_code' => $toothCode], + ); + + return $tooth->fresh(); + } + + /** + * @return array, conditions: list, notes: ?string}> + */ + public function teethMap(DentalChart $chart): array + { + $map = []; + foreach ($chart->teeth as $tooth) { + $map[$tooth->tooth_code] = [ + 'status' => $tooth->status, + 'surfaces' => $tooth->surfaces ?? [], + 'conditions' => $tooth->conditions ?? [], + 'notes' => $tooth->notes, + ]; + } + + return $map; + } +} diff --git a/app/Services/Care/Dentistry/DentalImagingService.php b/app/Services/Care/Dentistry/DentalImagingService.php new file mode 100644 index 0000000..5a8b995 --- /dev/null +++ b/app/Services/Care/Dentistry/DentalImagingService.php @@ -0,0 +1,83 @@ +, caption?: ?string, bill_fee?: bool} $meta + */ + public function upload( + Organization $organization, + Visit $visit, + UploadedFile $file, + array $meta, + string $ownerRef, + ?string $actorRef = null, + ): DentalImage { + $visit->loadMissing('patient'); + $modality = (string) ($meta['modality'] ?? ''); + if (! array_key_exists($modality, DentalCatalog::modalities())) { + throw new \InvalidArgumentException("Invalid imaging modality [{$modality}]."); + } + + $toothCodes = array_values(array_filter( + $meta['tooth_codes'] ?? [], + fn ($c) => DentalCatalog::isValidToothCode((string) $c), + )); + + $path = $file->store("care/patients/{$visit->patient_id}/dental", 'public'); + + $attachment = PatientAttachment::create([ + 'owner_ref' => $ownerRef, + 'patient_id' => $visit->patient_id, + 'file_path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getMimeType(), + 'document_type' => 'dental_imaging_'.$modality, + 'uploaded_by' => $actorRef ?? $ownerRef, + ]); + + $image = DentalImage::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'patient_id' => $visit->patient_id, + 'visit_id' => $visit->id, + 'patient_attachment_id' => $attachment->id, + 'modality' => $modality, + 'tooth_codes' => $toothCodes, + 'caption' => $meta['caption'] ?? null, + 'uploaded_by' => $actorRef ?? $ownerRef, + 'captured_at' => now(), + ]); + + AuditLogger::record( + $ownerRef, + 'dental.image.uploaded', + $organization->id, + $actorRef, + DentalImage::class, + $image->id, + ); + + return $image->fresh('attachment'); + } + + public function url(DentalImage $image): ?string + { + $path = $image->attachment?->file_path; + if (! $path) { + return null; + } + + return Storage::disk('public')->url($path); + } +} diff --git a/app/Services/Care/Dentistry/DentalProcedureService.php b/app/Services/Care/Dentistry/DentalProcedureService.php new file mode 100644 index 0000000..07fac83 --- /dev/null +++ b/app/Services/Care/Dentistry/DentalProcedureService.php @@ -0,0 +1,133 @@ +, + * anesthesia?: ?string, + * notes?: ?string, + * plan_item_id?: ?int, + * bill?: bool + * } $data + */ + public function complete( + Organization $organization, + Visit $visit, + array $data, + string $ownerRef, + ?string $actorRef = null, + ): DentalProcedure { + $visit->loadMissing('patient'); + $code = (string) ($data['procedure_code'] ?? ''); + $service = collect($this->shell->provisionedServices($organization, 'dentistry')) + ->first(fn ($s) => ($s['code'] ?? '') === $code) + ?? DentalCatalog::defaultServices()[$code] ?? null; + + if (! $service) { + throw new \InvalidArgumentException("Unknown dental procedure [{$code}]."); + } + + $tooth = $data['tooth_code'] ?? null; + if ($tooth && ! DentalCatalog::isValidToothCode($tooth)) { + throw new \InvalidArgumentException("Invalid FDI tooth code [{$tooth}]."); + } + + $surfaces = array_values(array_intersect( + array_map('strtoupper', $data['surfaces'] ?? []), + DentalCatalog::surfaces(), + )); + + $planItem = null; + if (! empty($data['plan_item_id'])) { + $planItem = DentalPlanItem::query()->find((int) $data['plan_item_id']); + if ($planItem && $planItem->plan?->patient_id !== $visit->patient_id) { + throw new \InvalidArgumentException('Plan item does not belong to this patient.'); + } + } + + $label = $service['label']; + if ($tooth) { + $label .= ' · tooth '.$tooth; + if ($surfaces !== []) { + $label .= ' ('.implode('', $surfaces).')'; + } + } + + $amount = (int) ($service['amount_minor'] ?? 0); + + $procedure = DentalProcedure::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'patient_id' => $visit->patient_id, + 'visit_id' => $visit->id, + 'plan_item_id' => $planItem?->id, + 'tooth_code' => $tooth, + 'surfaces' => $surfaces, + 'procedure_code' => $code, + 'label' => $label, + 'anesthesia' => $data['anesthesia'] ?? null, + 'status' => DentalProcedure::STATUS_COMPLETED, + 'amount_minor' => $amount, + 'bill_line_item_id' => null, + 'provider_ref' => $actorRef ?? $ownerRef, + 'notes' => $data['notes'] ?? null, + 'performed_at' => now(), + ]); + + if ($data['bill'] ?? true) { + $bill = $this->bills->generateFromVisit($visit, $ownerRef, $actorRef); + $bill = $this->bills->addManualLineItem($bill, $ownerRef, [ + 'type' => $service['type'] ?? 'procedure', + 'description' => $label, + 'quantity' => 1, + 'unit_price_minor' => $amount, + 'source_type' => 'dental_procedure', + 'source_id' => $procedure->id, + ], $actorRef); + $billLineId = $bill->lineItems()->where('source_type', 'dental_procedure')->where('source_id', $procedure->id)->value('id'); + $procedure->update(['bill_line_item_id' => $billLineId]); + if ($planItem && $billLineId) { + $planItem->update(['bill_line_item_id' => $billLineId]); + } + } + + if ($planItem) { + $this->plans->markItemDone($planItem); + $plan = $planItem->plan; + if ($plan && $plan->status === DentalTreatmentPlan::STATUS_ACCEPTED) { + $plan->update(['status' => DentalTreatmentPlan::STATUS_IN_PROGRESS]); + } + } + + AuditLogger::record( + $ownerRef, + 'dental.procedure.completed', + $organization->id, + $actorRef, + DentalProcedure::class, + $procedure->id, + ); + + return $procedure->fresh(); + } +} diff --git a/app/Services/Care/Dentistry/DentalTreatmentPlanService.php b/app/Services/Care/Dentistry/DentalTreatmentPlanService.php new file mode 100644 index 0000000..a557979 --- /dev/null +++ b/app/Services/Care/Dentistry/DentalTreatmentPlanService.php @@ -0,0 +1,149 @@ +where('organization_id', $organization->id) + ->where('patient_id', $patient->id) + ->whereIn('status', [ + DentalTreatmentPlan::STATUS_DRAFT, + DentalTreatmentPlan::STATUS_ACCEPTED, + DentalTreatmentPlan::STATUS_IN_PROGRESS, + ]) + ->with('items') + ->latest('id') + ->first(); + } + + public function ensurePlan( + Organization $organization, + Patient $patient, + string $ownerRef, + ?Visit $visit = null, + ?string $actorRef = null, + ): DentalTreatmentPlan { + $plan = $this->activePlanForPatient($organization, $patient, $ownerRef); + if ($plan) { + return $plan; + } + + $plan = DentalTreatmentPlan::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'patient_id' => $patient->id, + 'visit_id' => $visit?->id, + 'status' => DentalTreatmentPlan::STATUS_DRAFT, + 'title' => 'Treatment plan', + 'created_by' => $actorRef ?? $ownerRef, + ]); + + AuditLogger::record($ownerRef, 'dental.plan.created', $organization->id, $actorRef, DentalTreatmentPlan::class, $plan->id); + + return $plan->fresh('items'); + } + + /** + * @param array{tooth_code?: ?string, surfaces?: list, procedure_code: string, priority?: int, notes?: ?string} $data + */ + public function addItem( + DentalTreatmentPlan $plan, + Organization $organization, + array $data, + string $ownerRef, + ?string $actorRef = null, + ): DentalPlanItem { + $code = (string) ($data['procedure_code'] ?? ''); + $service = collect($this->shell->provisionedServices($organization, 'dentistry')) + ->first(fn ($s) => ($s['code'] ?? '') === $code) + ?? DentalCatalog::defaultServices()[$code] ?? null; + + if (! $service) { + throw new \InvalidArgumentException("Unknown dental procedure [{$code}]."); + } + + $tooth = $data['tooth_code'] ?? null; + if ($tooth && ! DentalCatalog::isValidToothCode($tooth)) { + throw new \InvalidArgumentException("Invalid FDI tooth code [{$tooth}]."); + } + + $surfaces = array_values(array_intersect( + array_map('strtoupper', $data['surfaces'] ?? []), + DentalCatalog::surfaces(), + )); + + $item = DentalPlanItem::create([ + 'treatment_plan_id' => $plan->id, + 'tooth_code' => $tooth, + 'surfaces' => $surfaces, + 'procedure_code' => $code, + 'label' => $service['label'], + 'priority' => (int) ($data['priority'] ?? 50), + 'status' => $plan->status === DentalTreatmentPlan::STATUS_DRAFT + ? DentalPlanItem::STATUS_PROPOSED + : DentalPlanItem::STATUS_ACCEPTED, + 'estimate_minor' => (int) ($service['amount_minor'] ?? 0), + 'notes' => $data['notes'] ?? null, + ]); + + $this->recalculateEstimate($plan); + + AuditLogger::record($ownerRef, 'dental.plan.item_added', $organization->id, $actorRef, DentalPlanItem::class, $item->id); + + return $item; + } + + public function accept(DentalTreatmentPlan $plan, string $ownerRef, ?string $actorRef = null): DentalTreatmentPlan + { + $plan->update([ + 'status' => DentalTreatmentPlan::STATUS_ACCEPTED, + 'accepted_at' => now(), + ]); + $plan->items() + ->where('status', DentalPlanItem::STATUS_PROPOSED) + ->update(['status' => DentalPlanItem::STATUS_ACCEPTED]); + + AuditLogger::record($ownerRef, 'dental.plan.accepted', $plan->organization_id, $actorRef, DentalTreatmentPlan::class, $plan->id); + + return $plan->fresh('items'); + } + + public function markItemDone(DentalPlanItem $item): DentalPlanItem + { + $item->update(['status' => DentalPlanItem::STATUS_DONE]); + $plan = $item->plan; + if ($plan && $plan->items()->whereNotIn('status', [DentalPlanItem::STATUS_DONE, DentalPlanItem::STATUS_CANCELLED])->doesntExist()) { + $plan->update([ + 'status' => DentalTreatmentPlan::STATUS_COMPLETED, + 'completed_at' => now(), + ]); + } elseif ($plan && in_array($plan->status, [DentalTreatmentPlan::STATUS_ACCEPTED, DentalTreatmentPlan::STATUS_DRAFT], true)) { + $plan->update(['status' => DentalTreatmentPlan::STATUS_IN_PROGRESS]); + } + + return $item->fresh(); + } + + public function recalculateEstimate(DentalTreatmentPlan $plan): void + { + $total = (int) $plan->items() + ->whereNotIn('status', [DentalPlanItem::STATUS_CANCELLED]) + ->sum('estimate_minor'); + $plan->update(['estimate_minor' => $total]); + } +} diff --git a/app/Services/Care/Dentistry/DentalVisitNoteService.php b/app/Services/Care/Dentistry/DentalVisitNoteService.php new file mode 100644 index 0000000..0669c12 --- /dev/null +++ b/app/Services/Care/Dentistry/DentalVisitNoteService.php @@ -0,0 +1,51 @@ +loadMissing('patient'); + + $note = DentalVisitNote::owned($ownerRef) + ->where('visit_id', $visit->id) + ->first(); + + $attributes = [ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'visit_id' => $visit->id, + 'patient_id' => $visit->patient_id, + 'chief_complaint' => $data['chief_complaint'] ?? null, + 'soft_tissue' => $data['soft_tissue'] ?? null, + 'occlusion' => $data['occlusion'] ?? null, + 'notes' => $data['notes'] ?? null, + 'recorded_by' => $actorRef ?? $ownerRef, + 'recorded_at' => now(), + ]; + + if ($note) { + $note->update($attributes); + } else { + $note = DentalVisitNote::create($attributes); + } + + AuditLogger::record($ownerRef, 'dental.notes.saved', $organization->id, $actorRef, DentalVisitNote::class, $note->id); + + return $note->fresh(); + } +} diff --git a/app/Services/Care/SpecialtyModuleService.php b/app/Services/Care/SpecialtyModuleService.php index c325b30..0e85e9b 100644 --- a/app/Services/Care/SpecialtyModuleService.php +++ b/app/Services/Care/SpecialtyModuleService.php @@ -163,6 +163,10 @@ class SpecialtyModuleService return false; } + if (app(CarePermissions::class)->isAdmin($member)) { + return true; + } + $roles = $definition['roles'] ?? []; if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) { return false; diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php index fe04acc..8db85c9 100644 --- a/app/Services/Care/SpecialtyShellService.php +++ b/app/Services/Care/SpecialtyShellService.php @@ -72,12 +72,20 @@ class SpecialtyShellService */ public function provisionedServices(Organization $organization, string $moduleKey): array { + $catalog = collect($this->catalogServices($moduleKey))->keyBy(fn ($s) => (string) ($s['code'] ?? '')); $stored = data_get($organization->settings, "specialty_module_provisioning.{$moduleKey}.services"); - if (is_array($stored) && $stored !== []) { - return array_values($stored); + if (is_array($stored)) { + foreach ($stored as $service) { + $code = (string) ($service['code'] ?? ''); + if ($code === '') { + continue; + } + $prior = $catalog->get($code, []); + $catalog->put($code, array_merge(is_array($prior) ? $prior : [], $service)); + } } - return $this->catalogServices($moduleKey); + return $catalog->filter(fn ($s, $code) => $code !== '')->values()->all(); } /** @@ -271,7 +279,20 @@ class SpecialtyShellService $clinicalOpen = match ($moduleKey) { 'emergency' => $clinical->countOpenByType($organization, 'emergency', 'triage', $branchScope), 'blood_bank' => $clinical->countOpenByType($organization, 'blood_bank', 'blood_request', $branchScope), - 'dentistry' => $clinical->countOpenByType($organization, 'dentistry', 'odontogram', $branchScope), + 'dentistry' => \App\Models\DentalPlanItem::query() + ->whereHas('plan', function ($q) use ($organization, $ownerRef) { + $q->owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('status', [ + \App\Models\DentalTreatmentPlan::STATUS_ACCEPTED, + \App\Models\DentalTreatmentPlan::STATUS_IN_PROGRESS, + ]); + }) + ->whereIn('status', [ + \App\Models\DentalPlanItem::STATUS_ACCEPTED, + \App\Models\DentalPlanItem::STATUS_PROPOSED, + ]) + ->count(), default => 0, }; diff --git a/config/care_specialty_clinical.php b/config/care_specialty_clinical.php index b2b98da..96db63e 100644 --- a/config/care_specialty_clinical.php +++ b/config/care_specialty_clinical.php @@ -20,8 +20,6 @@ return [ 'inventory' => 'inventory_note', ], 'dentistry' => [ - 'odontogram' => 'odontogram', - 'clinical_notes' => 'clinical_note', ], 'ophthalmology' => [ 'exam' => 'eye_exam', @@ -145,20 +143,6 @@ return [ ], ], 'dentistry' => [ - 'odontogram' => [ - ['name' => 'teeth_affected', 'label' => 'Teeth affected (FDI numbers, comma-separated)', 'type' => 'text', 'required' => true], - ['name' => 'findings', 'label' => 'Chart findings', 'type' => 'textarea', 'required' => true, 'rows' => 3], - ['name' => 'planned_procedures', 'label' => 'Planned procedures', 'type' => 'textarea', 'rows' => 2], - ['name' => 'completed_procedures', 'label' => 'Completed this visit', 'type' => 'textarea', 'rows' => 2], - ['name' => 'anesthesia', 'label' => 'Anesthesia', 'type' => 'select', 'options' => ['None', 'Local', 'Sedation', 'GA']], - ['name' => 'occlusion_notes', 'label' => 'Occlusion / bite notes', 'type' => 'text'], - ], - 'clinical_note' => [ - ['name' => 'history', 'label' => 'History', 'type' => 'textarea', 'required' => true, 'rows' => 3], - ['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3], - ['name' => 'working_diagnosis', 'label' => 'Diagnosis', 'type' => 'text'], - ['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 2], - ], ], 'ophthalmology' => [ 'eye_exam' => [ diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index a13f6ed..892745b 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -101,15 +101,27 @@ return [ ], 'services' => [ ['code' => 'den.consult', 'label' => 'Dental consultation', 'amount_minor' => 5000, 'type' => 'consultation'], + ['code' => 'den.exam', 'label' => 'Dental examination', 'amount_minor' => 4000, 'type' => 'consultation'], ['code' => 'den.cleaning', 'label' => 'Scaling / polishing', 'amount_minor' => 8000, 'type' => 'procedure'], ['code' => 'den.fill', 'label' => 'Filling', 'amount_minor' => 12000, 'type' => 'procedure'], ['code' => 'den.extraction', 'label' => 'Extraction', 'amount_minor' => 15000, 'type' => 'procedure'], - ['code' => 'den.xray', 'label' => 'Dental X-ray', 'amount_minor' => 4000, 'type' => 'imaging'], + ['code' => 'den.rct', 'label' => 'Root canal treatment', 'amount_minor' => 35000, 'type' => 'procedure'], + ['code' => 'den.crown', 'label' => 'Crown', 'amount_minor' => 45000, 'type' => 'procedure'], + ['code' => 'den.bridge', 'label' => 'Bridge unit', 'amount_minor' => 40000, 'type' => 'procedure'], + ['code' => 'den.implant_consult', 'label' => 'Implant consultation', 'amount_minor' => 10000, 'type' => 'consultation'], + ['code' => 'den.perio', 'label' => 'Periodontal treatment', 'amount_minor' => 20000, 'type' => 'procedure'], + ['code' => 'den.xray_pa', 'label' => 'Periapical X-ray', 'amount_minor' => 3000, 'type' => 'imaging'], + ['code' => 'den.xray_bw', 'label' => 'Bitewing X-ray', 'amount_minor' => 3500, 'type' => 'imaging'], + ['code' => 'den.xray_opg', 'label' => 'OPG / panoramic X-ray', 'amount_minor' => 8000, 'type' => 'imaging'], + ['code' => 'den.photo', 'label' => 'Clinical photography', 'amount_minor' => 2000, 'type' => 'imaging'], ], 'workspace_tabs' => [ 'overview' => 'Overview', 'odontogram' => 'Chart', - 'clinical_notes' => 'Clinical notes', + 'plan' => 'Plan', + 'treat' => 'Treat', + 'notes' => 'Notes', + 'imaging' => 'Imaging', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', diff --git a/database/migrations/2026_07_18_160000_create_care_dentistry_tables.php b/database/migrations/2026_07_18_160000_create_care_dentistry_tables.php new file mode 100644 index 0000000..a76ae2d --- /dev/null +++ b/database/migrations/2026_07_18_160000_create_care_dentistry_tables.php @@ -0,0 +1,185 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref', 64)->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + $table->string('notation', 16)->default('fdi'); + $table->timestamp('charted_at')->nullable(); + $table->string('charted_by')->nullable(); + $table->timestamps(); + + $table->unique(['patient_id']); + $table->index(['organization_id', 'patient_id']); + }); + } + + if (! Schema::hasTable('care_dental_teeth')) { + Schema::create('care_dental_teeth', function (Blueprint $table) { + $table->id(); + $table->foreignId('dental_chart_id')->constrained('care_dental_charts')->cascadeOnDelete(); + $table->string('tooth_code', 8); + $table->string('status', 32)->default('present'); + $table->json('surfaces')->nullable(); + $table->json('conditions')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->unique(['dental_chart_id', 'tooth_code']); + }); + } + + if (! Schema::hasTable('care_dental_chart_events')) { + Schema::create('care_dental_chart_events', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref', 64)->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('dental_chart_id')->constrained('care_dental_charts')->cascadeOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + $table->foreignId('visit_id')->nullable()->constrained('care_visits')->nullOnDelete(); + $table->string('tooth_code', 8)->nullable(); + $table->string('event_type', 64); + $table->json('payload')->nullable(); + $table->string('recorded_by')->nullable(); + $table->timestamp('recorded_at')->nullable(); + $table->timestamps(); + + $table->index(['dental_chart_id', 'recorded_at']); + $table->index(['visit_id']); + }); + } + + if (! Schema::hasTable('care_dental_treatment_plans')) { + Schema::create('care_dental_treatment_plans', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref', 64)->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + $table->foreignId('visit_id')->nullable()->constrained('care_visits')->nullOnDelete(); + $table->string('status', 32)->default('draft')->index(); + $table->string('title')->nullable(); + $table->unsignedInteger('estimate_minor')->default(0); + $table->timestamp('accepted_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->string('created_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['organization_id', 'patient_id', 'status']); + }); + } + + if (! Schema::hasTable('care_dental_plan_items')) { + Schema::create('care_dental_plan_items', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('treatment_plan_id')->constrained('care_dental_treatment_plans')->cascadeOnDelete(); + $table->string('tooth_code', 8)->nullable(); + $table->json('surfaces')->nullable(); + $table->string('procedure_code', 64); + $table->string('label'); + $table->unsignedSmallInteger('priority')->default(50); + $table->string('status', 32)->default('proposed')->index(); + $table->unsignedInteger('estimate_minor')->default(0); + $table->foreignId('bill_line_item_id')->nullable()->constrained('care_bill_line_items')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['treatment_plan_id', 'status']); + }); + } + + if (! Schema::hasTable('care_dental_procedures')) { + Schema::create('care_dental_procedures', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref', 64)->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + $table->foreignId('visit_id')->constrained('care_visits')->cascadeOnDelete(); + $table->foreignId('plan_item_id')->nullable()->constrained('care_dental_plan_items')->nullOnDelete(); + $table->string('tooth_code', 8)->nullable(); + $table->json('surfaces')->nullable(); + $table->string('procedure_code', 64); + $table->string('label'); + $table->string('anesthesia', 32)->nullable(); + $table->string('status', 32)->default('completed')->index(); + $table->unsignedInteger('amount_minor')->default(0); + $table->foreignId('bill_line_item_id')->nullable()->constrained('care_bill_line_items')->nullOnDelete(); + $table->string('provider_ref')->nullable(); + $table->text('notes')->nullable(); + $table->timestamp('performed_at')->nullable(); + $table->timestamps(); + + $table->index(['visit_id', 'status']); + $table->index(['organization_id', 'procedure_code']); + }); + } + + if (! Schema::hasTable('care_dental_images')) { + Schema::create('care_dental_images', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref', 64)->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + $table->foreignId('visit_id')->nullable()->constrained('care_visits')->nullOnDelete(); + $table->foreignId('patient_attachment_id')->constrained('care_patient_attachments')->cascadeOnDelete(); + $table->string('modality', 32); + $table->json('tooth_codes')->nullable(); + $table->string('caption')->nullable(); + $table->string('uploaded_by')->nullable(); + $table->timestamp('captured_at')->nullable(); + $table->timestamps(); + + $table->index(['visit_id', 'modality']); + $table->index(['patient_id', 'captured_at']); + }); + } + + if (! Schema::hasTable('care_dental_visit_notes')) { + Schema::create('care_dental_visit_notes', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref', 64)->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('visit_id')->constrained('care_visits')->cascadeOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + $table->text('chief_complaint')->nullable(); + $table->text('soft_tissue')->nullable(); + $table->text('occlusion')->nullable(); + $table->text('notes')->nullable(); + $table->string('recorded_by')->nullable(); + $table->timestamp('recorded_at')->nullable(); + $table->timestamps(); + + $table->unique(['visit_id']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('care_dental_visit_notes'); + Schema::dropIfExists('care_dental_images'); + Schema::dropIfExists('care_dental_procedures'); + Schema::dropIfExists('care_dental_plan_items'); + Schema::dropIfExists('care_dental_treatment_plans'); + Schema::dropIfExists('care_dental_chart_events'); + Schema::dropIfExists('care_dental_teeth'); + Schema::dropIfExists('care_dental_charts'); + } +}; diff --git a/resources/views/care/specialty/dentistry/print.blade.php b/resources/views/care/specialty/dentistry/print.blade.php new file mode 100644 index 0000000..0b5c67b --- /dev/null +++ b/resources/views/care/specialty/dentistry/print.blade.php @@ -0,0 +1,83 @@ + + + + + Dental chart · {{ $patient->fullName() }} + + + + +

{{ $patient->fullName() }}

+

+ {{ $patient->patient_number }} + · Visit {{ $visit->uuid }} + · Charted {{ $chart->charted_at?->format('d M Y H:i') ?? '—' }} +

+ +

Odontogram (FDI)

+ @foreach (['upper' => 'Upper', 'lower' => 'Lower'] as $key => $label) +

{{ $label }}

+
+ @foreach ($rows[$key] as $code) + @php $t = $teethMap[$code] ?? []; $finding = ($t['status'] ?? 'present') !== 'present' || !empty($t['conditions']); @endphp +
{{ $code }}
+ @endforeach +
+ @endforeach + +

Findings

+ + + + + + @foreach ($teethMap as $code => $t) + @if (($t['status'] ?? 'present') !== 'present' || ! empty($t['conditions']) || ! empty($t['notes'])) + + + + + + + + @endif + @endforeach + +
ToothStatusSurfacesConditionsNotes
{{ $code }}{{ $t['status'] ?? 'present' }}{{ implode('', $t['surfaces'] ?? []) }} + @foreach ($t['conditions'] ?? [] as $c) + {{ $conditions[$c] ?? $c }}@if(!$loop->last), @endif + @endforeach + {{ $t['notes'] ?? '' }}
+ + @if ($plan) +

Treatment plan ({{ $plan->status }})

+ + + + + + @foreach ($plan->items as $item) + + + + + + + @endforeach + +
ProcedureToothStatusEstimate
{{ $item->label }}{{ $item->tooth_code ?? '—' }}{{ $item->status }}{{ number_format($item->estimate_minor / 100, 2) }}
+ @endif + + diff --git a/resources/views/care/specialty/dentistry/reports.blade.php b/resources/views/care/specialty/dentistry/reports.blade.php new file mode 100644 index 0000000..cc9dadb --- /dev/null +++ b/resources/views/care/specialty/dentistry/reports.blade.php @@ -0,0 +1,72 @@ + +
+
+
+

Dentistry reports

+

Procedures, revenue, unfinished plans, and imaging volume.

+
+ ← Back to queue +
+ +
+
+

Unfinished plan items

+

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

+
+
+

Avg chair time (month)

+

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

+
+
+

Imaging studies (month)

+

+ {{ number_format($report['imaging_by_modality']->sum('total')) }} +

+
+
+ +
+

Procedures today

+
    + @forelse ($report['procedures_today'] as $row) +
  • + {{ $row->label }} × {{ $row->total }} + {{ $currency }} {{ number_format($row->amount_minor / 100, 2) }} +
  • + @empty +
  • No procedures today.
  • + @endforelse +
+
+ +
+

Procedures this month

+
    + @forelse ($report['procedures_month'] as $row) +
  • + {{ $row->label }} × {{ $row->total }} + {{ $currency }} {{ number_format($row->amount_minor / 100, 2) }} +
  • + @empty +
  • No procedures this month.
  • + @endforelse +
+
+ +
+

Imaging by modality (month)

+
    + @forelse ($report['imaging_by_modality'] as $row) +
  • + {{ $modalities[$row->modality] ?? $row->modality }} + {{ $row->total }} +
  • + @empty +
  • No imaging this month.
  • + @endforelse +
+
+
+
diff --git a/resources/views/care/specialty/dentistry/workspace-imaging.blade.php b/resources/views/care/specialty/dentistry/workspace-imaging.blade.php new file mode 100644 index 0000000..52df537 --- /dev/null +++ b/resources/views/care/specialty/dentistry/workspace-imaging.blade.php @@ -0,0 +1,77 @@ +
+

Dental imaging

+

Upload PA, bitewing, OPG, or clinical photos linked to teeth.

+ +
+ @csrf +
+
+ + +
+
+ + +
+
+ + +
+
+

Teeth

+
+ @foreach (array_merge($dentalRows['upper'] ?? [], $dentalRows['lower'] ?? []) as $code) + + @endforeach +
+
+
+ +
+
+ +
+ +
+ @forelse ($dentalImages as $image) + @php + $url = $image->attachment?->file_path + ? \Illuminate\Support\Facades\Storage::disk('public')->url($image->attachment->file_path) + : null; + @endphp +
+ @if ($url && str_starts_with((string) $image->attachment?->mime_type, 'image/')) + + {{ $image->caption ?: $image->modality }} + + @elseif ($url) + Open file + @endif +
+

{{ $dentalModalities[$image->modality] ?? $image->modality }}

+

+ {{ $image->captured_at?->format('d M Y H:i') }} + @if (! empty($image->tooth_codes)) + · {{ implode(', ', $image->tooth_codes) }} + @endif +

+ @if ($image->caption) +

{{ $image->caption }}

+ @endif +
+
+ @empty +

No images yet.

+ @endforelse +
+
diff --git a/resources/views/care/specialty/dentistry/workspace-notes.blade.php b/resources/views/care/specialty/dentistry/workspace-notes.blade.php new file mode 100644 index 0000000..788f17d --- /dev/null +++ b/resources/views/care/specialty/dentistry/workspace-notes.blade.php @@ -0,0 +1,25 @@ +
+

Dental visit notes

+

Short clinical notes for this visit — not a GP SOAP form.

+ +
+ @csrf +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
diff --git a/resources/views/care/specialty/dentistry/workspace-odontogram.blade.php b/resources/views/care/specialty/dentistry/workspace-odontogram.blade.php new file mode 100644 index 0000000..3ef7f7a --- /dev/null +++ b/resources/views/care/specialty/dentistry/workspace-odontogram.blade.php @@ -0,0 +1,101 @@ +@php + $teethMap = $dentalTeethMap ?? []; + $selected = old('tooth_code', request('tooth', '16')); + $selectedData = $teethMap[$selected] ?? ['status' => 'present', 'surfaces' => [], 'conditions' => [], 'notes' => null]; +@endphp + +
+
+
+

Odontogram (FDI)

+

Click a tooth to chart status, surfaces, and conditions. Changes are saved to the patient chart.

+
+ Print chart / plan +
+ +
+ @foreach (['upper' => 'Upper', 'lower' => 'Lower'] as $rowKey => $rowLabel) +
+

{{ $rowLabel }}

+
+ @foreach (($dentalRows[$rowKey] ?? []) as $code) + @php + $t = $teethMap[$code] ?? ['status' => 'present', 'conditions' => []]; + $hasFinding = ($t['status'] ?? 'present') !== 'present' || ! empty($t['conditions']); + @endphp + $code === $selected, + 'border-amber-300 bg-amber-50 text-amber-900' => $hasFinding && $code !== $selected, + 'border-slate-200 bg-slate-50 text-slate-700 hover:border-slate-300' => ! $hasFinding && $code !== $selected, + ]) + title="Tooth {{ $code }}" + > + {{ $code }} + + @endforeach +
+
+ @endforeach +
+ +
+ @csrf + + +
+ +

{{ $selected }}

+
+ +
+ + +
+ +
+

Surfaces

+
+ @foreach ($dentalSurfaces as $surface) + + @endforeach +
+
+ +
+

Conditions

+
+ @foreach ($dentalConditions as $value => $label) + + @endforeach +
+
+ +
+ + +
+ +
+ +
+
+
diff --git a/resources/views/care/specialty/dentistry/workspace-overview.blade.php b/resources/views/care/specialty/dentistry/workspace-overview.blade.php new file mode 100644 index 0000000..1dd74ca --- /dev/null +++ b/resources/views/care/specialty/dentistry/workspace-overview.blade.php @@ -0,0 +1,73 @@ +@php + $plan = $dentalPlan; + $openItems = $plan?->items?->whereIn('status', ['proposed', 'accepted']) ?? collect(); +@endphp + +
+
+
+

Overview

+

Visit summary and open treatment work.

+
+ Dentistry reports +
+ +
+
+
Visit status
+
{{ ucfirst(str_replace('_', ' ', $workspaceVisit->status)) }}
+
+
+
Checked in
+
{{ $workspaceVisit->checked_in_at?->format('d M Y H:i') ?? '—' }}
+
+
+
Queue
+
+ {{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }} + @if ($workspaceVisit->appointment?->queue_ticket_status) + ({{ $workspaceVisit->appointment->queue_ticket_status }}) + @endif +
+
+
+
Treatment plan
+
+ @if ($plan) + {{ ucfirst(str_replace('_', ' ', $plan->status)) }} + · {{ $currency ?? 'GHS' }} {{ number_format($plan->estimate_minor / 100, 2) }} + @else + None open + @endif +
+
+
+ + @if ($openItems->isNotEmpty()) +
+

Unfinished plan items

+
    + @foreach ($openItems->take(8) as $item) +
  • + + {{ $item->label }} + @if ($item->tooth_code) · {{ $item->tooth_code }} @endif + + {{ number_format($item->estimate_minor / 100, 2) }} +
  • + @endforeach +
+
+ @endif + + @if (! empty($clinicalAlerts)) +
+

Alerts

+ @foreach ($clinicalAlerts as $alert) +

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

+ @endforeach +
+ @endif +
diff --git a/resources/views/care/specialty/dentistry/workspace-plan.blade.php b/resources/views/care/specialty/dentistry/workspace-plan.blade.php new file mode 100644 index 0000000..6ead14a --- /dev/null +++ b/resources/views/care/specialty/dentistry/workspace-plan.blade.php @@ -0,0 +1,92 @@ +@php + $plan = $dentalPlan; +@endphp + +
+
+
+

Treatment plan

+

+ @if ($plan) + Status: {{ ucfirst(str_replace('_', ' ', $plan->status)) }} + · Estimate {{ $currency ?? 'GHS' }} {{ number_format($plan->estimate_minor / 100, 2) }} + @else + Create a plan by adding the first item. + @endif +

+
+ @if ($plan && $plan->status === 'draft') +
+ @csrf + +
+ @endif +
+ +
    + @forelse ($plan?->items ?? [] as $item) +
  • +
    +

    + {{ $item->label }} + @if ($item->tooth_code) + · {{ $item->tooth_code }} + @endif + @if (! empty($item->surfaces)) + ({{ implode('', $item->surfaces) }}) + @endif +

    +

    Priority {{ $item->priority }} · {{ ucfirst($item->status) }}

    +
    + {{ number_format($item->estimate_minor / 100, 2) }} +
  • + @empty +
  • No plan items yet.
  • + @endforelse +
+ +
+ @csrf +

Add item

+
+
+ + +
+
+ + +
+
+ + +
+
+

Surfaces

+
+ @foreach ($dentalSurfaces as $surface) + + @endforeach +
+
+
+ + +
+
+ +
+
diff --git a/resources/views/care/specialty/dentistry/workspace-treat.blade.php b/resources/views/care/specialty/dentistry/workspace-treat.blade.php new file mode 100644 index 0000000..390c5ca --- /dev/null +++ b/resources/views/care/specialty/dentistry/workspace-treat.blade.php @@ -0,0 +1,97 @@ +
+

Treat today

+

Record a completed procedure. It posts to the visit bill automatically.

+ + @if ($dentalPlan?->items) +
+

From plan

+
    + @foreach ($dentalPlan->items->whereIn('status', ['proposed', 'accepted']) as $item) +
  • +
    + @csrf + + + + + @foreach ($item->surfaces ?? [] as $surface) + + @endforeach +
    +

    {{ $item->label }} @if($item->tooth_code)· {{ $item->tooth_code }}@endif

    +

    {{ number_format($item->estimate_minor / 100, 2) }}

    +
    + +
    +
  • + @endforeach +
+
+ @endif + +
+ @csrf +

Ad-hoc procedure

+ +
+
+ + +
+
+ + +
+
+ + +
+
+

Surfaces

+
+ @foreach ($dentalSurfaces as $surface) + + @endforeach +
+
+
+ + +
+
+ +
+ +
+

Completed this visit

+
    + @forelse ($dentalProcedures as $proc) +
  • + {{ $proc->label }} · {{ $proc->performed_at?->format('H:i') }} + {{ number_format($proc->amount_minor / 100, 2) }} +
  • + @empty +
  • No procedures yet.
  • + @endforelse +
+
+
diff --git a/resources/views/care/specialty/partials/actions-menu.blade.php b/resources/views/care/specialty/partials/actions-menu.blade.php index d2624e3..339283b 100644 --- a/resources/views/care/specialty/partials/actions-menu.blade.php +++ b/resources/views/care/specialty/partials/actions-menu.blade.php @@ -20,10 +20,10 @@ $canCompleteConsultation = $consultationInProgress && $openConsultation && $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED; - $chartTab = collect($workspaceTabs ?? []) - ->keys() - ->first(fn ($key) => ! in_array($key, ['overview', 'timeline', 'orders', 'billing', 'documents'], true)) - ?: 'clinical_notes'; + $chartTab = collect($workspaceTabs ?? []) + ->keys() + ->first(fn ($key) => ! in_array($key, ['overview', 'timeline', 'plan', 'treat', 'notes', 'imaging', 'orders', 'billing', 'documents'], true)) + ?: 'odontogram'; @endphp @if ($onWorkspace) diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index e0578c1..c6c54ff 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -36,6 +36,8 @@
@if ($activeTab === 'timeline') @include('care.partials.patient-timeline', ['events' => $timeline ?? []]) + @elseif ($moduleKey === 'dentistry' && in_array($activeTab, ['odontogram', 'plan', 'treat', 'notes', 'imaging', 'overview'], true)) + @include('care.specialty.dentistry.workspace-'.$activeTab) @elseif ($activeTab === 'billing')

Add specialty service

diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index 119a0b2..935e6a2 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -13,6 +13,9 @@
← Back to queue
+ @if ($moduleKey === 'dentistry') + Reports + @endif History Billing
diff --git a/routes/web.php b/routes/web.php index 913712f..6b8922c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,6 +34,7 @@ use App\Http\Controllers\Care\ReportController; use App\Http\Controllers\Care\ServiceQueueController; use App\Http\Controllers\Care\SettingsController; use App\Http\Controllers\Care\SettingsModulesController; +use App\Http\Controllers\Care\DentistryWorkspaceController; use App\Http\Controllers\Care\SpecialtyModuleController; use App\Http\Controllers\Care\VisitWorkflowController; use App\Http\Controllers\NotificationController; @@ -111,11 +112,19 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/specialty/{module}/visits', [SpecialtyModuleController::class, 'visits'])->name('care.specialty.visits'); Route::get('/specialty/{module}/history', [SpecialtyModuleController::class, 'history'])->name('care.specialty.history'); Route::get('/specialty/{module}/billing', [SpecialtyModuleController::class, 'billing'])->name('care.specialty.billing'); + Route::get('/specialty/dentistry/reports', [DentistryWorkspaceController::class, 'reports'])->name('care.specialty.dentistry.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'); Route::post('/specialty/{module}/workspace/{visit}/services', [SpecialtyModuleController::class, 'addService'])->name('care.specialty.services.add'); Route::post('/specialty/{module}/workspace/{visit}/start-consultation', [SpecialtyModuleController::class, 'startConsultation'])->name('care.specialty.consultation.start'); + Route::post('/specialty/dentistry/workspace/{visit}/tooth', [DentistryWorkspaceController::class, 'updateTooth'])->name('care.specialty.dentistry.tooth'); + Route::post('/specialty/dentistry/workspace/{visit}/plan-items', [DentistryWorkspaceController::class, 'addPlanItem'])->name('care.specialty.dentistry.plan-items'); + Route::post('/specialty/dentistry/workspace/{visit}/plan/accept', [DentistryWorkspaceController::class, 'acceptPlan'])->name('care.specialty.dentistry.plan.accept'); + Route::post('/specialty/dentistry/workspace/{visit}/procedures', [DentistryWorkspaceController::class, 'completeProcedure'])->name('care.specialty.dentistry.procedures'); + Route::post('/specialty/dentistry/workspace/{visit}/notes', [DentistryWorkspaceController::class, 'saveNotes'])->name('care.specialty.dentistry.notes'); + Route::post('/specialty/dentistry/workspace/{visit}/images', [DentistryWorkspaceController::class, 'uploadImage'])->name('care.specialty.dentistry.images'); + Route::get('/specialty/dentistry/workspace/{visit}/print', [DentistryWorkspaceController::class, 'printChart'])->name('care.specialty.dentistry.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'); diff --git a/tests/Feature/CareDentistrySuiteTest.php b/tests/Feature/CareDentistrySuiteTest.php new file mode 100644 index 0000000..fa50e7b --- /dev/null +++ b/tests/Feature/CareDentistrySuiteTest.php @@ -0,0 +1,269 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'den-owner', + 'name' => 'Owner', + 'email' => 'den-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Dental Clinic', + 'slug' => 'dental-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, + 'dentistry', + ); + + $department = Department::query() + ->where('branch_id', $this->branch->id) + ->where('type', 'dental') + ->firstOrFail(); + + $this->patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-DEN-SUITE', + 'first_name' => 'Afia', + 'last_name' => 'Darko', + 'gender' => 'female', + 'date_of_birth' => '1992-01-01', + ]); + + $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(), + ]); + + 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_chart_tab_renders_odontogram_not_text_form(): void + { + $this->actingAs($this->owner) + ->get(route('care.specialty.workspace', [ + 'module' => 'dentistry', + 'visit' => $this->visit, + 'tab' => 'odontogram', + ])) + ->assertOk() + ->assertSee('Odontogram (FDI)') + ->assertSee('18') + ->assertSee('Save tooth') + ->assertDontSee('Teeth affected (FDI numbers'); + } + + public function test_updating_tooth_persists_chart_and_event(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.dentistry.tooth', $this->visit), [ + 'tooth_code' => '16', + 'status' => 'present', + 'surfaces' => ['O', 'M'], + 'conditions' => ['caries'], + 'notes' => 'Occlusal caries', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'dentistry', + 'visit' => $this->visit, + 'tab' => 'odontogram', + ])); + + $chart = DentalChart::query()->where('patient_id', $this->patient->id)->first(); + $this->assertNotNull($chart); + $tooth = $chart->teeth()->where('tooth_code', '16')->first(); + $this->assertSame(['O', 'M'], $tooth->surfaces); + $this->assertSame(['caries'], $tooth->conditions); + $this->assertDatabaseHas('care_dental_chart_events', [ + 'dental_chart_id' => $chart->id, + 'tooth_code' => '16', + 'event_type' => 'tooth_updated', + ]); + $this->assertSame(1, DentalChartEvent::query()->where('dental_chart_id', $chart->id)->count()); + } + + public function test_plan_procedure_and_bill_flow(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.dentistry.plan-items', $this->visit), [ + 'procedure_code' => 'den.fill', + 'tooth_code' => '16', + 'surfaces' => ['O'], + 'priority' => 10, + ]) + ->assertRedirect(); + + $plan = DentalTreatmentPlan::query()->where('patient_id', $this->patient->id)->first(); + $this->assertNotNull($plan); + $item = $plan->items()->first(); + $this->assertSame('den.fill', $item->procedure_code); + + $this->actingAs($this->owner) + ->post(route('care.specialty.dentistry.plan.accept', $this->visit)) + ->assertRedirect(); + + $this->assertSame(DentalTreatmentPlan::STATUS_ACCEPTED, $plan->fresh()->status); + + $this->actingAs($this->owner) + ->post(route('care.specialty.dentistry.procedures', $this->visit), [ + 'procedure_code' => 'den.fill', + 'tooth_code' => '16', + 'surfaces' => ['O'], + 'plan_item_id' => $item->id, + 'bill' => 1, + 'anesthesia' => 'Local', + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'dentistry', + 'visit' => $this->visit, + 'tab' => 'treat', + ])); + + $procedure = DentalProcedure::query()->where('visit_id', $this->visit->id)->first(); + $this->assertNotNull($procedure); + $this->assertNotNull($procedure->bill_line_item_id); + $this->assertSame(DentalPlanItem::STATUS_DONE, $item->fresh()->status); + $this->assertDatabaseHas('care_bill_line_items', [ + 'id' => $procedure->bill_line_item_id, + 'source_type' => 'dental_procedure', + 'source_id' => $procedure->id, + ]); + } + + public function test_imaging_upload_and_reports_page(): void + { + Storage::fake('public'); + + $this->actingAs($this->owner) + ->post(route('care.specialty.dentistry.images', $this->visit), [ + 'attachment' => UploadedFile::fake()->image('xray.jpg'), + 'modality' => 'pa', + 'tooth_codes' => ['16'], + 'caption' => 'PA 16', + 'add_fee' => 0, + ]) + ->assertRedirect(route('care.specialty.workspace', [ + 'module' => 'dentistry', + 'visit' => $this->visit, + 'tab' => 'imaging', + ])); + + $this->assertDatabaseHas('care_dental_images', [ + 'visit_id' => $this->visit->id, + 'modality' => 'pa', + ]); + + $this->actingAs($this->owner) + ->get(route('care.specialty.dentistry.reports')) + ->assertOk() + ->assertSee('Dentistry reports'); + } + + public function test_notes_and_print_views(): void + { + $this->actingAs($this->owner) + ->post(route('care.specialty.dentistry.notes', $this->visit), [ + 'chief_complaint' => 'Toothache upper right', + 'soft_tissue' => 'WNL', + 'occlusion' => 'Class I', + 'notes' => 'Demo note', + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('care_dental_visit_notes', [ + 'visit_id' => $this->visit->id, + 'chief_complaint' => 'Toothache upper right', + ]); + + $this->actingAs($this->owner) + ->get(route('care.specialty.dentistry.print', $this->visit)) + ->assertOk() + ->assertSee('Odontogram (FDI)') + ->assertSee('Afia Darko'); + } +}