diff --git a/app/Http/Controllers/Care/DentistryWorkspaceController.php b/app/Http/Controllers/Care/DentistryWorkspaceController.php index c1f1ddb..26da720 100644 --- a/app/Http/Controllers/Care/DentistryWorkspaceController.php +++ b/app/Http/Controllers/Care/DentistryWorkspaceController.php @@ -4,16 +4,24 @@ namespace App\Http\Controllers\Care; use App\Http\Controllers\Care\Concerns\ScopesToAccount; use App\Http\Controllers\Controller; +use App\Models\DentalImage; +use App\Models\DentalLabCase; +use App\Models\DentalPlanItem; +use App\Models\DentalRecall; use App\Models\Visit; use App\Services\Care\Dentistry\DentalAnalyticsService; use App\Services\Care\Dentistry\DentalCatalog; use App\Services\Care\Dentistry\DentalChartService; use App\Services\Care\Dentistry\DentalImagingService; +use App\Services\Care\Dentistry\DentalLabCaseService; +use App\Services\Care\Dentistry\DentalPerioService; use App\Services\Care\Dentistry\DentalProcedureService; +use App\Services\Care\Dentistry\DentalRecallService; use App\Services\Care\Dentistry\DentalTreatmentPlanService; use App\Services\Care\Dentistry\DentalVisitNoteService; use App\Services\Care\SpecialtyModuleService; use App\Services\Care\SpecialtyShellService; +use App\Services\Care\SpecialtyVisitStageService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -279,10 +287,13 @@ class DentistryWorkspaceController extends Controller $member = $this->member($request); $branchScope = app(\App\Services\Care\OrganizationResolver::class)->branchScope($member); + $from = $request->query('from'); + $to = $request->query('to'); + return view('care.specialty.dentistry.reports', [ 'moduleKey' => 'dentistry', 'definition' => $modules->definition('dentistry'), - 'report' => $analytics->report($organization, $this->ownerRef($request), $branchScope), + 'report' => $analytics->report($organization, $this->ownerRef($request), $branchScope, $from, $to), 'modalities' => DentalCatalog::modalities(), 'currency' => config('care.billing.currency', 'GHS'), 'shellNav' => $shell->navItems('dentistry'), @@ -316,4 +327,460 @@ class DentistryWorkspaceController extends Controller 'conditions' => DentalCatalog::conditions(), ]); } + + public function setDentition( + 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([ + 'dentition' => ['required', 'string', 'in:adult,primary,mixed'], + ]); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + $visit->loadMissing('patient'); + $chart = $charts->chartForPatient($organization, $visit->patient, $owner); + + try { + $charts->setDentition( + $chart, + $validated['dentition'], + $owner, + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'odontogram']) + ->with('success', 'Dentition updated.'); + } + + public function updatePlanItem( + Request $request, + Visit $visit, + DentalPlanItem $item, + 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->activePlanForPatient($organization, $visit->patient, $owner); + abort_unless($plan && $item->treatment_plan_id === $plan->id, 404); + + try { + $plans->updateItem($item, $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', 'Plan item updated.'); + } + + public function cancelPlanItem( + Request $request, + Visit $visit, + DentalPlanItem $item, + 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); + abort_unless($plan && $item->treatment_plan_id === $plan->id, 404); + + try { + $plans->cancelItem($item, $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', 'Plan item cancelled.'); + } + + public function rejectPlan( + 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([ + 'rejection_reason' => ['nullable', 'string', 'max:2000'], + ]); + + $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.'); + } + + try { + $plans->rejectPlan( + $plan, + $owner, + $validated['rejection_reason'] ?? null, + $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', 'Treatment plan rejected.'); + } + + public function cancelPlan( + 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.'); + } + + try { + $plans->cancelPlan($plan, $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', 'Treatment plan cancelled.'); + } + + public function voidImage( + Request $request, + Visit $visit, + DentalImage $image, + SpecialtyModuleService $modules, + DentalImagingService $imaging, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $visit->loadMissing('patient'); + abort_unless( + $image->organization_id === $organization->id + && ($image->visit_id === $visit->id || $image->patient_id === $visit->patient_id), + 404, + ); + + try { + $imaging->void($image, $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 removed.'); + } + + public function setStage( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + SpecialtyVisitStageService $stages, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'stage' => ['required', 'string', 'max:32'], + ]); + + try { + $stages->setStage( + $this->organization($request), + $visit, + 'dentistry', + $validated['stage'], + $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' => 'overview']) + ->with('success', 'Visit stage updated.'); + } + + public function savePerio( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + DentalPerioService $perio, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'sites' => ['nullable', 'array'], + 'sites.*.tooth_code' => ['required', 'string', 'max:8'], + 'sites.*.surface' => ['required', 'string', 'max:8'], + 'sites.*.probing_mm' => ['nullable', 'integer', 'min:0', 'max:20'], + 'sites.*.bleeding' => ['nullable', 'boolean'], + 'sites.*.plaque' => ['nullable', 'boolean'], + 'sites.*.recession_mm' => ['nullable', 'integer', 'min:0', 'max:20'], + 'notes' => ['nullable', 'string', 'max:5000'], + ]); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + $visit->loadMissing('patient'); + + try { + $perio->saveExam( + $organization, + $visit->patient, + $owner, + $validated['sites'] ?? [], + $visit, + $validated['notes'] ?? null, + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'perio']) + ->with('success', 'Perio exam saved.'); + } + + public function createLabCase( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + DentalLabCaseService $labCases, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'case_type' => ['required', 'string', 'in:crown,bridge,denture,veneer,ortho,other'], + 'lab_name' => ['nullable', 'string', 'max:255'], + 'tooth_codes' => ['nullable', 'array'], + 'tooth_codes.*' => ['string', 'max:8'], + 'due_at' => ['nullable', 'date'], + 'notes' => ['nullable', 'string', 'max:2000'], + 'status' => ['nullable', 'string', 'in:draft,ordered,sent,received,fitted,cancelled'], + ]); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + $visit->loadMissing('patient'); + + try { + $labCases->create( + $organization, + $visit->patient, + $owner, + $validated, + $visit, + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'lab']) + ->with('success', 'Lab case created.'); + } + + public function transitionLabCase( + Request $request, + Visit $visit, + DentalLabCase $labCase, + SpecialtyModuleService $modules, + DentalLabCaseService $labCases, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'status' => ['required', 'string', 'in:draft,ordered,sent,received,fitted,cancelled'], + ]); + + $organization = $this->organization($request); + $visit->loadMissing('patient'); + abort_unless( + $labCase->organization_id === $organization->id + && $labCase->patient_id === $visit->patient_id, + 404, + ); + + try { + $labCases->transition( + $labCase, + $validated['status'], + $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' => 'lab']) + ->with('success', 'Lab case updated.'); + } + + public function scheduleRecall( + Request $request, + Visit $visit, + SpecialtyModuleService $modules, + DentalRecallService $recalls, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $validated = $request->validate([ + 'due_on' => ['required', 'date'], + 'reason' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]); + + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + $visit->loadMissing('patient'); + + try { + $recalls->schedule( + $organization, + $visit->patient, + $owner, + $validated, + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'recalls']) + ->with('success', 'Recall scheduled.'); + } + + public function completeRecall( + Request $request, + Visit $visit, + DentalRecall $recall, + SpecialtyModuleService $modules, + DentalRecallService $recalls, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $visit->loadMissing('patient'); + abort_unless( + $recall->organization_id === $organization->id + && $recall->patient_id === $visit->patient_id, + 404, + ); + + try { + $recalls->complete( + $recall, + $this->ownerRef($request), + $visit, + $this->actorRef($request), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'recalls']) + ->with('success', 'Recall completed.'); + } + + public function cancelRecall( + Request $request, + Visit $visit, + DentalRecall $recall, + SpecialtyModuleService $modules, + DentalRecallService $recalls, + ): RedirectResponse { + $this->authorizeAbility($request, 'consultations.manage'); + $this->assertDentistryAccess($request, $modules); + $this->assertVisit($request, $visit); + + $organization = $this->organization($request); + $visit->loadMissing('patient'); + abort_unless( + $recall->organization_id === $organization->id + && $recall->patient_id === $visit->patient_id, + 404, + ); + + try { + $recalls->cancel($recall, $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' => 'recalls']) + ->with('success', 'Recall cancelled.'); + } } diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 5558e79..13ec7bb 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -197,6 +197,31 @@ class SpecialtyModuleController extends Controller } $visit = $visit->fresh() ?? $appointment->visit; + if ($visit) { + $stageService = app(\App\Services\Care\SpecialtyVisitStageService::class); + $nextStage = $stageService->stageOnConsultationStart($module); + if ($nextStage && ! $visit->specialty_stage) { + try { + $stageService->setStage( + $organization, + $visit, + $module, + $nextStage, + $owner, + $owner, + ); + $visit = $visit->fresh(); + } catch (\InvalidArgumentException) { + // Stage map may be empty for some modules. + } + } elseif ($nextStage && $visit->specialty_stage === 'waiting') { + try { + $stageService->setStage($organization, $visit, $module, $nextStage, $owner, $owner); + $visit = $visit->fresh(); + } catch (\InvalidArgumentException) { + } + } + } $clinical = app(SpecialtyClinicalRecordService::class); $shellTabs = app(SpecialtyShellService::class)->workspaceTabs($module); $preferredTab = $module === 'dentistry' @@ -512,6 +537,7 @@ class SpecialtyModuleController extends Controller $dentalChart = null; $dentalTeethMap = []; $dentalPlan = null; + $dentalPlanHistory = collect(); $dentalProcedures = collect(); $dentalImages = collect(); $dentalVisitNote = null; @@ -521,6 +547,14 @@ class SpecialtyModuleController extends Controller $dentalStatuses = []; $dentalSurfaces = []; $dentalModalities = []; + $dentalDentitions = []; + $dentalPerioExam = null; + $dentalPerioSurfaces = []; + $dentalLabCases = collect(); + $dentalLabCaseTypes = []; + $dentalLabCaseStatuses = []; + $dentalRecalls = collect(); + $dentalStageCodes = []; $activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview'); $clinical = app(SpecialtyClinicalRecordService::class); @@ -576,10 +610,14 @@ class SpecialtyModuleController extends Controller $chartService = app(\App\Services\Care\Dentistry\DentalChartService::class); $planService = app(\App\Services\Care\Dentistry\DentalTreatmentPlanService::class); $analytics = app(\App\Services\Care\Dentistry\DentalAnalyticsService::class); + $perioService = app(\App\Services\Care\Dentistry\DentalPerioService::class); + $labService = app(\App\Services\Care\Dentistry\DentalLabCaseService::class); + $recallService = app(\App\Services\Care\Dentistry\DentalRecallService::class); $dentalChart = $chartService->chartForPatient($organization, $workspaceVisit->patient, $owner); $dentalTeethMap = $chartService->teethMap($dentalChart); $dentalPlan = $planService->activePlanForPatient($organization, $workspaceVisit->patient, $owner); + $dentalPlanHistory = $planService->plansForPatient($organization, $workspaceVisit->patient, $owner); $dentalProcedures = \App\Models\DentalProcedure::owned($owner) ->where('visit_id', $workspaceVisit->id) ->orderByDesc('id') @@ -600,11 +638,20 @@ class SpecialtyModuleController extends Controller ->where('visit_id', $workspaceVisit->id) ->first(); $dentalCatalog = $shell->provisionedServices($organization, 'dentistry'); - $dentalRows = \App\Services\Care\Dentistry\DentalCatalog::odontogramRows(); + $dentition = $dentalChart->dentition ?: \App\Services\Care\Dentistry\DentalCatalog::DENTITION_ADULT; + $dentalRows = \App\Services\Care\Dentistry\DentalCatalog::odontogramRows($dentition); $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(); + $dentalDentitions = \App\Services\Care\Dentistry\DentalCatalog::dentitions(); + $dentalPerioExam = $perioService->latestForPatient($organization, $workspaceVisit->patient, $owner); + $dentalPerioSurfaces = \App\Services\Care\Dentistry\DentalCatalog::perioSurfaces(); + $dentalLabCases = $labService->casesForPatient($organization, $workspaceVisit->patient, $owner); + $dentalLabCaseTypes = \App\Services\Care\Dentistry\DentalCatalog::labCaseTypes(); + $dentalLabCaseStatuses = \App\Services\Care\Dentistry\DentalCatalog::labCaseStatuses(); + $dentalRecalls = $recallService->recallsForPatient($organization, $workspaceVisit->patient, $owner); + $dentalStageCodes = collect($shell->stages('dentistry'))->pluck('code')->all(); $clinicalAlerts = array_merge( $clinicalAlerts, @@ -664,6 +711,7 @@ class SpecialtyModuleController extends Controller 'dentalChart' => $dentalChart, 'dentalTeethMap' => $dentalTeethMap, 'dentalPlan' => $dentalPlan, + 'dentalPlanHistory' => $dentalPlanHistory, 'dentalProcedures' => $dentalProcedures, 'dentalImages' => $dentalImages, 'dentalVisitNote' => $dentalVisitNote, @@ -673,6 +721,14 @@ class SpecialtyModuleController extends Controller 'dentalStatuses' => $dentalStatuses, 'dentalSurfaces' => $dentalSurfaces, 'dentalModalities' => $dentalModalities, + 'dentalDentitions' => $dentalDentitions, + 'dentalPerioExam' => $dentalPerioExam, + 'dentalPerioSurfaces' => $dentalPerioSurfaces, + 'dentalLabCases' => $dentalLabCases, + 'dentalLabCaseTypes' => $dentalLabCaseTypes, + 'dentalLabCaseStatuses' => $dentalLabCaseStatuses, + 'dentalRecalls' => $dentalRecalls, + 'dentalStageCodes' => $dentalStageCodes, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, 'branchLabel' => $branchLabel, diff --git a/app/Models/DentalChart.php b/app/Models/DentalChart.php index fcd98af..38aa9f4 100644 --- a/app/Models/DentalChart.php +++ b/app/Models/DentalChart.php @@ -16,7 +16,7 @@ class DentalChart extends Model protected $fillable = [ 'uuid', 'owner_ref', 'organization_id', 'patient_id', - 'notation', 'charted_at', 'charted_by', + 'notation', 'dentition', 'charted_at', 'charted_by', ]; protected function casts(): array diff --git a/app/Models/DentalImage.php b/app/Models/DentalImage.php index c7da420..df03f7e 100644 --- a/app/Models/DentalImage.php +++ b/app/Models/DentalImage.php @@ -5,11 +5,12 @@ namespace App\Models; use App\Models\Concerns\BelongsToOwner; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; class DentalImage extends Model { - use BelongsToOwner; + use BelongsToOwner, SoftDeletes; public const MODALITY_PA = 'pa'; diff --git a/app/Models/DentalLabCase.php b/app/Models/DentalLabCase.php new file mode 100644 index 0000000..f5ac60e --- /dev/null +++ b/app/Models/DentalLabCase.php @@ -0,0 +1,72 @@ + 'array', + 'ordered_at' => 'datetime', + 'due_at' => 'date', + 'received_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (DentalLabCase $case) { + if (! $case->uuid) { + $case->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function planItem(): BelongsTo + { + return $this->belongsTo(DentalPlanItem::class, 'plan_item_id'); + } +} diff --git a/app/Models/DentalPerioExam.php b/app/Models/DentalPerioExam.php new file mode 100644 index 0000000..1fcbc2b --- /dev/null +++ b/app/Models/DentalPerioExam.php @@ -0,0 +1,57 @@ + 'date', + ]; + } + + protected static function booted(): void + { + static::creating(function (DentalPerioExam $exam) { + if (! $exam->uuid) { + $exam->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function sites(): HasMany + { + return $this->hasMany(DentalPerioSite::class, 'perio_exam_id'); + } +} diff --git a/app/Models/DentalPerioSite.php b/app/Models/DentalPerioSite.php new file mode 100644 index 0000000..1d017c3 --- /dev/null +++ b/app/Models/DentalPerioSite.php @@ -0,0 +1,29 @@ + 'boolean', + 'plaque' => 'boolean', + ]; + } + + public function exam(): BelongsTo + { + return $this->belongsTo(DentalPerioExam::class, 'perio_exam_id'); + } +} diff --git a/app/Models/DentalRecall.php b/app/Models/DentalRecall.php new file mode 100644 index 0000000..1f342e5 --- /dev/null +++ b/app/Models/DentalRecall.php @@ -0,0 +1,59 @@ + 'date', + ]; + } + + protected static function booted(): void + { + static::creating(function (DentalRecall $recall) { + if (! $recall->uuid) { + $recall->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function linkedVisit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'linked_visit_id'); + } +} diff --git a/app/Models/DentalTreatmentPlan.php b/app/Models/DentalTreatmentPlan.php index dcaac53..cd74303 100644 --- a/app/Models/DentalTreatmentPlan.php +++ b/app/Models/DentalTreatmentPlan.php @@ -21,11 +21,16 @@ class DentalTreatmentPlan extends Model public const STATUS_COMPLETED = 'completed'; + public const STATUS_REJECTED = 'rejected'; + + public const STATUS_CANCELLED = 'cancelled'; + protected $table = 'care_dental_treatment_plans'; protected $fillable = [ 'uuid', 'owner_ref', 'organization_id', 'patient_id', 'visit_id', - 'status', 'title', 'estimate_minor', 'accepted_at', 'completed_at', 'created_by', + 'status', 'title', 'estimate_minor', 'accepted_at', 'completed_at', + 'rejected_at', 'rejection_reason', 'created_by', ]; protected function casts(): array @@ -33,6 +38,7 @@ class DentalTreatmentPlan extends Model return [ 'accepted_at' => 'datetime', 'completed_at' => 'datetime', + 'rejected_at' => 'datetime', ]; } diff --git a/app/Models/Visit.php b/app/Models/Visit.php index db054d9..48d1a3c 100644 --- a/app/Models/Visit.php +++ b/app/Models/Visit.php @@ -24,7 +24,7 @@ class Visit extends Model protected $fillable = [ 'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_id', - 'status', 'checked_in_at', 'completed_at', 'checked_in_by', + 'status', 'specialty_stage', 'checked_in_at', 'completed_at', 'checked_in_by', ]; protected function casts(): array diff --git a/app/Services/Care/CareQueueProvisioner.php b/app/Services/Care/CareQueueProvisioner.php index 57adf83..2bdcfc5 100644 --- a/app/Services/Care/CareQueueProvisioner.php +++ b/app/Services/Care/CareQueueProvisioner.php @@ -207,13 +207,16 @@ class CareQueueProvisioner CareQueueContexts::CONSULTATION, $priorByKey, ), - CareQueueContexts::isSpecialty($context) => $this->practitionerPoints( - $organization, - $branch, - $context, - $priorByKey, - data_get($organization->settings, "specialty_module_provisioning.{$context}.department_ids", []), - ), + CareQueueContexts::isSpecialty($context) => array_values(array_merge( + $this->practitionerPoints( + $organization, + $branch, + $context, + $priorByKey, + data_get($organization->settings, "specialty_module_provisioning.{$context}.department_ids", []), + ), + $this->specialtyStagePoints($organization, $branch, $context, $priorByKey), + )), $context === CareQueueContexts::TRIAGE => $this->memberRolePoints( $organization, $branch, @@ -276,6 +279,52 @@ class CareQueueProvisioner return $points; } + /** + * Fixed stage points for specialty modules that define distinct queue_point values + * (e.g. dentistry waiting / chair / recovery). + * + * @param \Illuminate\Support\Collection> $priorByKey + * @return list> + */ + protected function specialtyStagePoints( + Organization $organization, + Branch $branch, + string $context, + $priorByKey, + ): array { + if ($context !== 'dentistry') { + return []; + } + + $stages = app(SpecialtyShellService::class)->stages($context); + $seen = []; + $points = []; + foreach ($stages as $stage) { + $queuePoint = $stage['queue_point'] ?? null; + if (! is_string($queuePoint) || $queuePoint === '' || isset($seen[$queuePoint])) { + continue; + } + $seen[$queuePoint] = true; + $key = CareQueueContexts::pointExternalKey($context, $branch->id, 'stage', $queuePoint); + $prior = $priorByKey->get($key, []); + $label = (string) ($stage['label'] ?? ucfirst($queuePoint)); + $points[] = [ + 'kind' => 'stage', + 'stage' => $queuePoint, + 'ref_id' => 0, + 'name' => $label, + 'destination' => $label, + 'staff_ref' => null, + 'staff_display_name' => null, + 'external_key' => $key, + 'counter_uuid' => $prior['counter_uuid'] ?? null, + 'synced' => false, + ]; + } + + return $points; + } + /** * @param list|null $departmentIds * @param \Illuminate\Support\Collection> $priorByKey diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 92cb425..6c6208a 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -1026,6 +1026,39 @@ class DemoTenantSeeder // Billing may be constrained in partial seeds — chart/plan still valuable. } } + + $perio = app(\App\Services\Care\Dentistry\DentalPerioService::class); + if (! \App\Models\DentalPerioExam::owned($ownerRef)->where('patient_id', $appointment->patient_id)->exists()) { + $perio->saveExam($organization, $appointment->patient, $ownerRef, [ + ['tooth_code' => '16', 'surface' => 'B', 'probing_mm' => 3, 'bleeding' => true, 'plaque' => false], + ['tooth_code' => '16', 'surface' => 'L', 'probing_mm' => 2, 'bleeding' => false, 'plaque' => true], + ], $appointment->visit, 'Demo perio chart', $ownerRef); + } + + $lab = app(\App\Services\Care\Dentistry\DentalLabCaseService::class); + if (! \App\Models\DentalLabCase::owned($ownerRef)->where('patient_id', $appointment->patient_id)->exists()) { + $lab->create($organization, $appointment->patient, $ownerRef, [ + 'case_type' => 'crown', + 'lab_name' => 'Demo Dental Lab', + 'tooth_codes' => ['16'], + 'status' => 'ordered', + 'due_at' => now()->addDays(14)->toDateString(), + 'notes' => 'Demo crown case', + ], $appointment->visit, $ownerRef); + } + + $recalls = app(\App\Services\Care\Dentistry\DentalRecallService::class); + if (! \App\Models\DentalRecall::owned($ownerRef)->where('patient_id', $appointment->patient_id)->exists()) { + $recalls->schedule($organization, $appointment->patient, $ownerRef, [ + 'due_on' => now()->addMonths(6)->toDateString(), + 'reason' => '6-month check', + 'notes' => 'Demo recall', + ], $ownerRef); + } + + if (! $appointment->visit->specialty_stage) { + $appointment->visit->update(['specialty_stage' => 'chair']); + } } /** diff --git a/app/Services/Care/Dentistry/DentalAnalyticsService.php b/app/Services/Care/Dentistry/DentalAnalyticsService.php index f202d76..67a8aa0 100644 --- a/app/Services/Care/Dentistry/DentalAnalyticsService.php +++ b/app/Services/Care/Dentistry/DentalAnalyticsService.php @@ -7,9 +7,11 @@ use App\Models\BillLineItem; use App\Models\DentalImage; use App\Models\DentalPlanItem; use App\Models\DentalProcedure; +use App\Models\DentalRecall; use App\Models\DentalTreatmentPlan; use App\Models\Organization; use App\Models\Patient; +use Carbon\Carbon; use Illuminate\Support\Collection; class DentalAnalyticsService @@ -21,16 +23,21 @@ class DentalAnalyticsService * revenue_by_code: Collection, * unfinished_items: int, * avg_chair_minutes: ?float, - * imaging_by_modality: Collection + * imaging_by_modality: Collection, + * from: string, + * to: string * } */ public function report( Organization $organization, string $ownerRef, ?int $branchId = null, + ?string $from = null, + ?string $to = null, ): array { $todayStart = now()->startOfDay(); - $monthStart = now()->startOfMonth(); + $rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth(); + $rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay(); $procBase = DentalProcedure::owned($ownerRef) ->where('organization_id', $organization->id) @@ -44,7 +51,7 @@ class DentalAnalyticsService ->get(); $proceduresMonth = (clone $procBase) - ->where('performed_at', '>=', $monthStart) + ->whereBetween('performed_at', [$rangeFrom, $rangeTo]) ->selectRaw('procedure_code, label, count(*) as total, sum(amount_minor) as amount_minor') ->groupBy('procedure_code', 'label') ->orderByDesc('total') @@ -53,7 +60,7 @@ class DentalAnalyticsService $revenueByCode = BillLineItem::query() ->where('owner_ref', $ownerRef) ->where('source_type', 'dental_procedure') - ->where('created_at', '>=', $monthStart) + ->whereBetween('created_at', [$rangeFrom, $rangeTo]) ->selectRaw('description, sum(total_minor) as amount_minor, count(*) as total') ->groupBy('description') ->orderByDesc('amount_minor') @@ -77,7 +84,7 @@ class DentalAnalyticsService ->where('status', Appointment::STATUS_COMPLETED) ->whereNotNull('started_at') ->whereNotNull('completed_at') - ->where('completed_at', '>=', $monthStart) + ->whereBetween('completed_at', [$rangeFrom, $rangeTo]) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)); $avgChair = null; @@ -88,7 +95,7 @@ class DentalAnalyticsService $imaging = DentalImage::owned($ownerRef) ->where('organization_id', $organization->id) - ->where('captured_at', '>=', $monthStart) + ->whereBetween('captured_at', [$rangeFrom, $rangeTo]) ->selectRaw('modality, count(*) as total') ->groupBy('modality') ->orderByDesc('total') @@ -101,6 +108,8 @@ class DentalAnalyticsService 'unfinished_items' => $unfinished, 'avg_chair_minutes' => $avgChair, 'imaging_by_modality' => $imaging, + 'from' => $rangeFrom->toDateString(), + 'to' => $rangeTo->toDateString(), ]; } @@ -150,6 +159,24 @@ class DentalAnalyticsService } } + $overdue = DentalRecall::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('patient_id', $patient->id) + ->whereIn('status', [DentalRecall::STATUS_SCHEDULED, DentalRecall::STATUS_DUE]) + ->where('due_on', '<', now()->toDateString()) + ->orderBy('due_on') + ->limit(3) + ->get(); + + foreach ($overdue as $recall) { + $reason = $recall->reason ?: 'Recall'; + $alerts[] = [ + 'code' => 'dental.recall.overdue', + 'severity' => 'warning', + 'message' => "Overdue recall ({$reason}) due {$recall->due_on->format('d M Y')}.", + ]; + } + return $alerts; } } diff --git a/app/Services/Care/Dentistry/DentalCatalog.php b/app/Services/Care/Dentistry/DentalCatalog.php index 0fb95a3..7bb9e4a 100644 --- a/app/Services/Care/Dentistry/DentalCatalog.php +++ b/app/Services/Care/Dentistry/DentalCatalog.php @@ -3,10 +3,16 @@ namespace App\Services\Care\Dentistry; /** - * FDI adult dentition helpers and catalog labels for dentistry. + * FDI adult + primary dentition helpers and catalog labels for dentistry. */ class DentalCatalog { + public const DENTITION_ADULT = 'adult'; + + public const DENTITION_PRIMARY = 'primary'; + + public const DENTITION_MIXED = 'mixed'; + /** @return list */ public static function adultToothCodes(): array { @@ -21,15 +27,52 @@ class DentalCatalog return $codes; } + /** @return list */ + public static function primaryToothCodes(): array + { + $codes = []; + foreach ([55, 54, 53, 52, 51, 61, 62, 63, 64, 65] as $n) { + $codes[] = (string) $n; + } + foreach ([85, 84, 83, 82, 81, 71, 72, 73, 74, 75] as $n) { + $codes[] = (string) $n; + } + + return $codes; + } + + /** @return list */ + public static function toothCodesForDentition(string $dentition): array + { + return match ($dentition) { + self::DENTITION_PRIMARY => self::primaryToothCodes(), + self::DENTITION_MIXED => array_values(array_unique(array_merge( + self::adultToothCodes(), + self::primaryToothCodes(), + ))), + default => self::adultToothCodes(), + }; + } + /** * @return array{upper: list, lower: list} */ - public static function odontogramRows(): array + public static function odontogramRows(?string $dentition = self::DENTITION_ADULT): 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 match ($dentition) { + self::DENTITION_PRIMARY => [ + 'upper' => ['55', '54', '53', '52', '51', '61', '62', '63', '64', '65'], + 'lower' => ['85', '84', '83', '82', '81', '71', '72', '73', '74', '75'], + ], + self::DENTITION_MIXED => [ + 'upper' => ['18', '17', '16', '55', '54', '53', '52', '51', '61', '62', '63', '64', '65', '26', '27', '28'], + 'lower' => ['48', '47', '46', '85', '84', '83', '82', '81', '71', '72', '73', '74', '75', '36', '37', '38'], + ], + default => [ + '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 */ @@ -38,6 +81,22 @@ class DentalCatalog return ['M', 'O', 'D', 'B', 'L', 'I']; } + /** @return list */ + public static function perioSurfaces(): array + { + return ['MB', 'B', 'DB', 'ML', 'L', 'DL']; + } + + /** @return array */ + public static function dentitions(): array + { + return [ + self::DENTITION_ADULT => 'Adult', + self::DENTITION_PRIMARY => 'Primary', + self::DENTITION_MIXED => 'Mixed', + ]; + } + /** @return array */ public static function conditions(): array { @@ -75,6 +134,32 @@ class DentalCatalog ]; } + /** @return array */ + public static function labCaseTypes(): array + { + return [ + 'crown' => 'Crown', + 'bridge' => 'Bridge', + 'denture' => 'Denture', + 'veneer' => 'Veneer', + 'ortho' => 'Ortho appliance', + 'other' => 'Other', + ]; + } + + /** @return array */ + public static function labCaseStatuses(): array + { + return [ + 'draft' => 'Draft', + 'ordered' => 'Ordered', + 'sent' => 'Sent to lab', + 'received' => 'Received', + 'fitted' => 'Fitted', + 'cancelled' => 'Cancelled', + ]; + } + /** @return array */ public static function defaultServices(): array { @@ -98,6 +183,27 @@ class DentalCatalog public static function isValidToothCode(string $code): bool { - return in_array($code, self::adultToothCodes(), true); + return in_array($code, self::toothCodesForDentition(self::DENTITION_MIXED), true); + } + + public static function toothCssClass(string $status, array $conditions = []): string + { + if (in_array($status, ['missing', 'extracted'], true) || in_array('missing', $conditions, true)) { + return 'bg-slate-200 text-slate-500 line-through'; + } + if ($status === 'implant' || in_array('implant', $conditions, true)) { + return 'bg-sky-100 text-sky-800 ring-1 ring-sky-300'; + } + if (in_array('caries', $conditions, true) || in_array('fracture', $conditions, true)) { + return 'bg-amber-100 text-amber-900 ring-1 ring-amber-400'; + } + if (in_array('filled', $conditions, true) || in_array('crown', $conditions, true) || in_array('root_canal', $conditions, true)) { + return 'bg-emerald-50 text-emerald-800 ring-1 ring-emerald-300'; + } + if (in_array('watch', $conditions, true)) { + return 'bg-violet-50 text-violet-800 ring-1 ring-violet-300'; + } + + return 'bg-white text-slate-800 ring-1 ring-slate-200'; } } diff --git a/app/Services/Care/Dentistry/DentalChartService.php b/app/Services/Care/Dentistry/DentalChartService.php index 635d0ee..6f522df 100644 --- a/app/Services/Care/Dentistry/DentalChartService.php +++ b/app/Services/Care/Dentistry/DentalChartService.php @@ -22,7 +22,9 @@ class DentalChartService ->first(); if ($chart) { - return $chart; + $this->ensureTeethForDentition($chart); + + return $chart->fresh('teeth'); } return DB::transaction(function () use ($organization, $patient, $ownerRef) { @@ -31,24 +33,78 @@ class DentalChartService 'organization_id' => $organization->id, 'patient_id' => $patient->id, 'notation' => 'fdi', + 'dentition' => DentalCatalog::DENTITION_ADULT, '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' => [], - ]); - } + $this->seedTeeth($chart, DentalCatalog::DENTITION_ADULT); return $chart->fresh('teeth'); }); } + public function setDentition( + DentalChart $chart, + string $dentition, + string $ownerRef, + ?string $actorRef = null, + ): DentalChart { + if (! array_key_exists($dentition, DentalCatalog::dentitions())) { + throw new \InvalidArgumentException("Invalid dentition [{$dentition}]."); + } + + $chart->update([ + 'dentition' => $dentition, + 'charted_at' => now(), + 'charted_by' => $actorRef ?? $ownerRef, + ]); + + $this->ensureTeethForDentition($chart->fresh()); + + AuditLogger::record( + $ownerRef, + 'dental.chart.dentition_set', + $chart->organization_id, + $actorRef, + DentalChart::class, + $chart->id, + ['dentition' => $dentition], + ); + + return $chart->fresh('teeth'); + } + + protected function ensureTeethForDentition(DentalChart $chart): void + { + $dentition = $chart->dentition ?: DentalCatalog::DENTITION_ADULT; + $needed = DentalCatalog::toothCodesForDentition($dentition); + $existing = $chart->teeth()->pluck('tooth_code')->all(); + $missing = array_diff($needed, $existing); + foreach ($missing as $code) { + DentalTooth::create([ + 'dental_chart_id' => $chart->id, + 'tooth_code' => $code, + 'status' => DentalTooth::STATUS_PRESENT, + 'surfaces' => [], + 'conditions' => [], + ]); + } + } + + protected function seedTeeth(DentalChart $chart, string $dentition): void + { + foreach (DentalCatalog::toothCodesForDentition($dentition) as $code) { + DentalTooth::create([ + 'dental_chart_id' => $chart->id, + 'tooth_code' => $code, + 'status' => DentalTooth::STATUS_PRESENT, + 'surfaces' => [], + 'conditions' => [], + ]); + } + } + /** * @param array{status?: string, surfaces?: list, conditions?: list, notes?: ?string} $data */ diff --git a/app/Services/Care/Dentistry/DentalImagingService.php b/app/Services/Care/Dentistry/DentalImagingService.php index 5a8b995..3f588f3 100644 --- a/app/Services/Care/Dentistry/DentalImagingService.php +++ b/app/Services/Care/Dentistry/DentalImagingService.php @@ -71,6 +71,20 @@ class DentalImagingService return $image->fresh('attachment'); } + public function void(DentalImage $image, string $ownerRef, ?string $actorRef = null): void + { + $image->delete(); + + AuditLogger::record( + $ownerRef, + 'dental.image.voided', + $image->organization_id, + $actorRef, + DentalImage::class, + $image->id, + ); + } + public function url(DentalImage $image): ?string { $path = $image->attachment?->file_path; diff --git a/app/Services/Care/Dentistry/DentalLabCaseService.php b/app/Services/Care/Dentistry/DentalLabCaseService.php new file mode 100644 index 0000000..6547540 --- /dev/null +++ b/app/Services/Care/Dentistry/DentalLabCaseService.php @@ -0,0 +1,100 @@ + + */ + public function casesForPatient(Organization $organization, Patient $patient, string $ownerRef, int $limit = 30): Collection + { + return DentalLabCase::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('patient_id', $patient->id) + ->latest('id') + ->limit($limit) + ->get(); + } + + /** + * @param array{case_type: string, lab_name?: ?string, tooth_codes?: list, plan_item_id?: ?int, due_at?: ?string, notes?: ?string, status?: string} $data + */ + public function create( + Organization $organization, + Patient $patient, + string $ownerRef, + array $data, + ?Visit $visit = null, + ?string $actorRef = null, + ): DentalLabCase { + $type = (string) ($data['case_type'] ?? ''); + if (! array_key_exists($type, DentalCatalog::labCaseTypes())) { + throw new \InvalidArgumentException("Invalid lab case type [{$type}]."); + } + + $status = (string) ($data['status'] ?? DentalLabCase::STATUS_DRAFT); + if (! array_key_exists($status, DentalCatalog::labCaseStatuses())) { + throw new \InvalidArgumentException("Invalid lab case status [{$status}]."); + } + + $toothCodes = array_values(array_filter( + $data['tooth_codes'] ?? [], + fn ($c) => DentalCatalog::isValidToothCode((string) $c), + )); + + $case = DentalLabCase::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'patient_id' => $patient->id, + 'visit_id' => $visit?->id, + 'plan_item_id' => $data['plan_item_id'] ?? null, + 'tooth_codes' => $toothCodes, + 'case_type' => $type, + 'lab_name' => $data['lab_name'] ?? null, + 'status' => $status, + 'ordered_at' => in_array($status, [DentalLabCase::STATUS_ORDERED, DentalLabCase::STATUS_SENT], true) ? now() : null, + 'due_at' => $data['due_at'] ?? null, + 'notes' => $data['notes'] ?? null, + 'created_by' => $actorRef ?? $ownerRef, + ]); + + AuditLogger::record($ownerRef, 'dental.lab.created', $organization->id, $actorRef, DentalLabCase::class, $case->id); + + return $case; + } + + public function transition( + DentalLabCase $case, + string $status, + string $ownerRef, + ?string $actorRef = null, + ): DentalLabCase { + if (! array_key_exists($status, DentalCatalog::labCaseStatuses())) { + throw new \InvalidArgumentException("Invalid lab case status [{$status}]."); + } + + $updates = ['status' => $status]; + if (in_array($status, [DentalLabCase::STATUS_ORDERED, DentalLabCase::STATUS_SENT], true) && ! $case->ordered_at) { + $updates['ordered_at'] = now(); + } + if (in_array($status, [DentalLabCase::STATUS_RECEIVED, DentalLabCase::STATUS_FITTED], true) && ! $case->received_at) { + $updates['received_at'] = now(); + } + + $case->update($updates); + + AuditLogger::record($ownerRef, 'dental.lab.transitioned', $case->organization_id, $actorRef, DentalLabCase::class, $case->id, [ + 'status' => $status, + ]); + + return $case->fresh(); + } +} diff --git a/app/Services/Care/Dentistry/DentalPerioService.php b/app/Services/Care/Dentistry/DentalPerioService.php new file mode 100644 index 0000000..acadfc8 --- /dev/null +++ b/app/Services/Care/Dentistry/DentalPerioService.php @@ -0,0 +1,94 @@ + + */ + public function examsForPatient(Organization $organization, Patient $patient, string $ownerRef, int $limit = 10): Collection + { + return DentalPerioExam::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('patient_id', $patient->id) + ->with('sites') + ->latest('id') + ->limit($limit) + ->get(); + } + + public function latestForPatient(Organization $organization, Patient $patient, string $ownerRef): ?DentalPerioExam + { + return $this->examsForPatient($organization, $patient, $ownerRef, 1)->first(); + } + + /** + * @param list $sites + */ + public function saveExam( + Organization $organization, + Patient $patient, + string $ownerRef, + array $sites, + ?Visit $visit = null, + ?string $notes = null, + ?string $actorRef = null, + ): DentalPerioExam { + return DB::transaction(function () use ($organization, $patient, $ownerRef, $sites, $visit, $notes, $actorRef) { + $exam = DentalPerioExam::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'patient_id' => $patient->id, + 'visit_id' => $visit?->id, + 'exam_date' => now()->toDateString(), + 'examiner' => $actorRef ?? $ownerRef, + 'notes' => $notes, + ]); + + foreach ($sites as $row) { + $tooth = (string) ($row['tooth_code'] ?? ''); + $surface = strtoupper((string) ($row['surface'] ?? '')); + if (! DentalCatalog::isValidToothCode($tooth) || ! in_array($surface, DentalCatalog::perioSurfaces(), true)) { + continue; + } + if (($row['probing_mm'] ?? null) === null + && empty($row['bleeding']) + && empty($row['plaque']) + && ($row['recession_mm'] ?? null) === null) { + continue; + } + + DentalPerioSite::create([ + 'perio_exam_id' => $exam->id, + 'tooth_code' => $tooth, + 'surface' => $surface, + 'probing_mm' => isset($row['probing_mm']) ? (int) $row['probing_mm'] : null, + 'bleeding' => (bool) ($row['bleeding'] ?? false), + 'plaque' => (bool) ($row['plaque'] ?? false), + 'recession_mm' => isset($row['recession_mm']) ? (int) $row['recession_mm'] : null, + ]); + } + + AuditLogger::record( + $ownerRef, + 'dental.perio.saved', + $organization->id, + $actorRef, + DentalPerioExam::class, + $exam->id, + ); + + return $exam->fresh('sites'); + }); + } +} diff --git a/app/Services/Care/Dentistry/DentalRecallService.php b/app/Services/Care/Dentistry/DentalRecallService.php new file mode 100644 index 0000000..12c123e --- /dev/null +++ b/app/Services/Care/Dentistry/DentalRecallService.php @@ -0,0 +1,100 @@ + + */ + public function recallsForPatient(Organization $organization, Patient $patient, string $ownerRef, int $limit = 30): Collection + { + return DentalRecall::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('patient_id', $patient->id) + ->orderBy('due_on') + ->limit($limit) + ->get(); + } + + /** + * @param array{due_on: string, reason?: ?string, notes?: ?string} $data + */ + public function schedule( + Organization $organization, + Patient $patient, + string $ownerRef, + array $data, + ?string $actorRef = null, + ): DentalRecall { + $dueOn = (string) ($data['due_on'] ?? ''); + if ($dueOn === '') { + throw new \InvalidArgumentException('Recall due date is required.'); + } + + $status = DentalRecall::STATUS_SCHEDULED; + if (now()->startOfDay()->gte(\Carbon\Carbon::parse($dueOn)->startOfDay())) { + $status = DentalRecall::STATUS_DUE; + } + + $recall = DentalRecall::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'patient_id' => $patient->id, + 'due_on' => $dueOn, + 'reason' => $data['reason'] ?? null, + 'status' => $status, + 'notes' => $data['notes'] ?? null, + 'created_by' => $actorRef ?? $ownerRef, + ]); + + AuditLogger::record($ownerRef, 'dental.recall.scheduled', $organization->id, $actorRef, DentalRecall::class, $recall->id); + + return $recall; + } + + public function complete( + DentalRecall $recall, + string $ownerRef, + ?Visit $visit = null, + ?string $actorRef = null, + ): DentalRecall { + $recall->update([ + 'status' => DentalRecall::STATUS_COMPLETED, + 'linked_visit_id' => $visit?->id ?? $recall->linked_visit_id, + ]); + + AuditLogger::record($ownerRef, 'dental.recall.completed', $recall->organization_id, $actorRef, DentalRecall::class, $recall->id); + + return $recall->fresh(); + } + + public function cancel(DentalRecall $recall, string $ownerRef, ?string $actorRef = null): DentalRecall + { + $recall->update(['status' => DentalRecall::STATUS_CANCELLED]); + + AuditLogger::record($ownerRef, 'dental.recall.cancelled', $recall->organization_id, $actorRef, DentalRecall::class, $recall->id); + + return $recall->fresh(); + } + + /** + * @return Collection + */ + public function openForPatient(Organization $organization, Patient $patient, string $ownerRef): Collection + { + return DentalRecall::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('patient_id', $patient->id) + ->whereIn('status', [DentalRecall::STATUS_SCHEDULED, DentalRecall::STATUS_DUE]) + ->orderBy('due_on') + ->get(); + } +} diff --git a/app/Services/Care/Dentistry/DentalTreatmentPlanService.php b/app/Services/Care/Dentistry/DentalTreatmentPlanService.php index a557979..8534124 100644 --- a/app/Services/Care/Dentistry/DentalTreatmentPlanService.php +++ b/app/Services/Care/Dentistry/DentalTreatmentPlanService.php @@ -9,6 +9,7 @@ use App\Models\Patient; use App\Models\Visit; use App\Services\Care\AuditLogger; use App\Services\Care\SpecialtyShellService; +use Illuminate\Support\Collection; class DentalTreatmentPlanService { @@ -31,6 +32,20 @@ class DentalTreatmentPlanService ->first(); } + /** + * @return Collection + */ + public function plansForPatient(Organization $organization, Patient $patient, string $ownerRef, int $limit = 20): Collection + { + return DentalTreatmentPlan::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('patient_id', $patient->id) + ->with('items') + ->latest('id') + ->limit($limit) + ->get(); + } + public function ensurePlan( Organization $organization, Patient $patient, @@ -68,6 +83,8 @@ class DentalTreatmentPlanService string $ownerRef, ?string $actorRef = null, ): DentalPlanItem { + $this->assertPlanMutable($plan); + $code = (string) ($data['procedure_code'] ?? ''); $service = collect($this->shell->provisionedServices($organization, 'dentistry')) ->first(fn ($s) => ($s['code'] ?? '') === $code) @@ -108,8 +125,104 @@ class DentalTreatmentPlanService return $item; } + /** + * @param array{tooth_code?: ?string, surfaces?: list, procedure_code?: string, priority?: int, notes?: ?string} $data + */ + public function updateItem( + DentalPlanItem $item, + Organization $organization, + array $data, + string $ownerRef, + ?string $actorRef = null, + ): DentalPlanItem { + if ($item->status === DentalPlanItem::STATUS_DONE) { + throw new \InvalidArgumentException('Completed plan items cannot be edited.'); + } + if ($item->status === DentalPlanItem::STATUS_CANCELLED) { + throw new \InvalidArgumentException('Cancelled plan items cannot be edited.'); + } + + $plan = $item->plan; + $this->assertPlanMutable($plan); + + $updates = []; + if (array_key_exists('procedure_code', $data) && $data['procedure_code']) { + $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}]."); + } + $updates['procedure_code'] = $code; + $updates['label'] = $service['label']; + $updates['estimate_minor'] = (int) ($service['amount_minor'] ?? 0); + } + + if (array_key_exists('tooth_code', $data)) { + $tooth = $data['tooth_code']; + if ($tooth && ! DentalCatalog::isValidToothCode($tooth)) { + throw new \InvalidArgumentException("Invalid FDI tooth code [{$tooth}]."); + } + $updates['tooth_code'] = $tooth ?: null; + } + + if (array_key_exists('surfaces', $data)) { + $updates['surfaces'] = array_values(array_intersect( + array_map('strtoupper', $data['surfaces'] ?? []), + DentalCatalog::surfaces(), + )); + } + + if (array_key_exists('priority', $data) && $data['priority'] !== null) { + $updates['priority'] = (int) $data['priority']; + } + + if (array_key_exists('notes', $data)) { + $updates['notes'] = $data['notes']; + } + + $item->update($updates); + $this->recalculateEstimate($plan); + + AuditLogger::record($ownerRef, 'dental.plan.item_updated', $organization->id, $actorRef, DentalPlanItem::class, $item->id); + + return $item->fresh(); + } + + public function cancelItem( + DentalPlanItem $item, + string $ownerRef, + ?string $actorRef = null, + ): DentalPlanItem { + if ($item->status === DentalPlanItem::STATUS_DONE) { + throw new \InvalidArgumentException('Completed plan items cannot be cancelled.'); + } + + $item->update(['status' => DentalPlanItem::STATUS_CANCELLED]); + $plan = $item->plan; + if ($plan) { + $this->recalculateEstimate($plan); + if ($plan->items()->whereNotIn('status', [DentalPlanItem::STATUS_DONE, DentalPlanItem::STATUS_CANCELLED])->doesntExist() + && $plan->items()->where('status', DentalPlanItem::STATUS_DONE)->exists()) { + $plan->update([ + 'status' => DentalTreatmentPlan::STATUS_COMPLETED, + 'completed_at' => now(), + ]); + } + } + + AuditLogger::record($ownerRef, 'dental.plan.item_cancelled', $plan?->organization_id, $actorRef, DentalPlanItem::class, $item->id); + + return $item->fresh(); + } + public function accept(DentalTreatmentPlan $plan, string $ownerRef, ?string $actorRef = null): DentalTreatmentPlan { + if ($plan->status !== DentalTreatmentPlan::STATUS_DRAFT) { + throw new \InvalidArgumentException('Only draft plans can be accepted.'); + } + $plan->update([ 'status' => DentalTreatmentPlan::STATUS_ACCEPTED, 'accepted_at' => now(), @@ -123,6 +236,51 @@ class DentalTreatmentPlanService return $plan->fresh('items'); } + public function rejectPlan( + DentalTreatmentPlan $plan, + string $ownerRef, + ?string $reason = null, + ?string $actorRef = null, + ): DentalTreatmentPlan { + if (! in_array($plan->status, [DentalTreatmentPlan::STATUS_DRAFT, DentalTreatmentPlan::STATUS_ACCEPTED], true)) { + throw new \InvalidArgumentException('Only draft or accepted plans can be rejected.'); + } + + $plan->update([ + 'status' => DentalTreatmentPlan::STATUS_REJECTED, + 'rejected_at' => now(), + 'rejection_reason' => $reason, + ]); + $plan->items() + ->whereNotIn('status', [DentalPlanItem::STATUS_DONE, DentalPlanItem::STATUS_CANCELLED]) + ->update(['status' => DentalPlanItem::STATUS_CANCELLED]); + + AuditLogger::record($ownerRef, 'dental.plan.rejected', $plan->organization_id, $actorRef, DentalTreatmentPlan::class, $plan->id); + + return $plan->fresh('items'); + } + + public function cancelPlan( + DentalTreatmentPlan $plan, + string $ownerRef, + ?string $actorRef = null, + ): DentalTreatmentPlan { + if ($plan->status === DentalTreatmentPlan::STATUS_COMPLETED) { + throw new \InvalidArgumentException('Completed plans cannot be cancelled.'); + } + + $plan->items() + ->whereNotIn('status', [DentalPlanItem::STATUS_DONE, DentalPlanItem::STATUS_CANCELLED]) + ->update(['status' => DentalPlanItem::STATUS_CANCELLED]); + + $plan->update(['status' => DentalTreatmentPlan::STATUS_CANCELLED]); + $plan->delete(); + + AuditLogger::record($ownerRef, 'dental.plan.cancelled', $plan->organization_id, $actorRef, DentalTreatmentPlan::class, $plan->id); + + return $plan; + } + public function markItemDone(DentalPlanItem $item): DentalPlanItem { $item->update(['status' => DentalPlanItem::STATUS_DONE]); @@ -146,4 +304,18 @@ class DentalTreatmentPlanService ->sum('estimate_minor'); $plan->update(['estimate_minor' => $total]); } + + protected function assertPlanMutable(?DentalTreatmentPlan $plan): void + { + if (! $plan) { + throw new \InvalidArgumentException('Plan not found.'); + } + if (in_array($plan->status, [ + DentalTreatmentPlan::STATUS_COMPLETED, + DentalTreatmentPlan::STATUS_REJECTED, + DentalTreatmentPlan::STATUS_CANCELLED, + ], true)) { + throw new \InvalidArgumentException('This treatment plan is closed.'); + } + } } diff --git a/app/Services/Care/SpecialtyShellService.php b/app/Services/Care/SpecialtyShellService.php index 8db85c9..226fb9e 100644 --- a/app/Services/Care/SpecialtyShellService.php +++ b/app/Services/Care/SpecialtyShellService.php @@ -259,14 +259,35 @@ class SpecialtyShellService ->whereNotIn('status', [Bill::STATUS_VOID]) ->sum('amount_paid_minor'); - $stages = collect($this->stages($moduleKey))->map(function (array $stage) use ($waiting, $inProgress, $completedToday) { + $stages = collect($this->stages($moduleKey))->map(function (array $stage) use ( + $waiting, + $inProgress, + $completedToday, + $moduleKey, + $organization, + $ownerRef, + $visitIds, + ) { $code = (string) ($stage['code'] ?? ''); - $count = match (true) { - in_array($code, ['waiting', 'arrival', 'request'], true) => $waiting, - in_array($code, ['in_care', 'chair', 'procedure', 'treatment', 'resus', 'crossmatch', 'issue', 'observation'], true) => $inProgress, - in_array($code, ['completed', 'disposition', 'transfusion', 'recovery'], true) => $completedToday, - default => 0, - }; + + if ($moduleKey === 'dentistry' && $visitIds->isNotEmpty()) { + $count = Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('id', $visitIds) + ->where('specialty_stage', $code) + ->when( + $code !== 'completed', + fn ($q) => $q->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]), + ) + ->count(); + } else { + $count = match (true) { + in_array($code, ['waiting', 'arrival', 'request'], true) => $waiting, + in_array($code, ['in_care', 'chair', 'procedure', 'treatment', 'resus', 'crossmatch', 'issue', 'observation'], true) => $inProgress, + in_array($code, ['completed', 'disposition', 'transfusion', 'recovery'], true) => $completedToday, + default => 0, + }; + } return [ 'code' => $code, diff --git a/app/Services/Care/SpecialtyVisitStageService.php b/app/Services/Care/SpecialtyVisitStageService.php new file mode 100644 index 0000000..d36264e --- /dev/null +++ b/app/Services/Care/SpecialtyVisitStageService.php @@ -0,0 +1,137 @@ + + */ + public function allowedStages(string $moduleKey): array + { + return collect($this->shell->stages($moduleKey)) + ->pluck('code') + ->filter() + ->values() + ->all(); + } + + public function setStage( + Organization $organization, + Visit $visit, + string $moduleKey, + string $stage, + string $ownerRef, + ?string $actorRef = null, + ): Visit { + $allowed = $this->allowedStages($moduleKey); + if (! in_array($stage, $allowed, true)) { + throw new \InvalidArgumentException("Invalid specialty stage [{$stage}] for {$moduleKey}."); + } + + $previous = $visit->specialty_stage; + $visit->update(['specialty_stage' => $stage]); + + if ($stage === 'completed' && $visit->status !== Visit::STATUS_COMPLETED) { + $visit->update([ + 'status' => Visit::STATUS_COMPLETED, + 'completed_at' => $visit->completed_at ?? now(), + ]); + } elseif ($stage !== 'completed' && $visit->status === Visit::STATUS_OPEN) { + $visit->update(['status' => Visit::STATUS_IN_PROGRESS]); + } + + $this->rerouteTicket($organization, $visit, $moduleKey, $stage); + + AuditLogger::record( + $ownerRef, + 'specialty.visit.stage', + $organization->id, + $actorRef, + Visit::class, + $visit->id, + ['module' => $moduleKey, 'from' => $previous, 'to' => $stage], + ); + + return $visit->fresh(); + } + + public function defaultOnCheckIn(string $moduleKey): ?string + { + $stages = $this->allowedStages($moduleKey); + + return $stages[0] ?? null; + } + + public function stageOnConsultationStart(string $moduleKey): ?string + { + $stages = $this->allowedStages($moduleKey); + foreach (['chair', 'in_care', 'exam', 'assessment'] as $preferred) { + if (in_array($preferred, $stages, true)) { + return $preferred; + } + } + + return $stages[1] ?? ($stages[0] ?? null); + } + + protected function rerouteTicket( + Organization $organization, + Visit $visit, + string $moduleKey, + string $stage, + ): void { + if (! $this->queueBridge->isEnabled($organization)) { + return; + } + + $stageMeta = collect($this->shell->stages($moduleKey)) + ->first(fn ($s) => ($s['code'] ?? '') === $stage); + $queuePoint = $stageMeta['queue_point'] ?? null; + if (! $queuePoint) { + return; + } + + $appointment = $visit->appointment; + if (! $appointment instanceof Appointment) { + $appointment = Appointment::query()->where('visit_id', $visit->id)->first(); + } + if (! $appointment?->queue_ticket_uuid) { + return; + } + + $ticket = CareQueueTicket::query() + ->where('uuid', $appointment->queue_ticket_uuid) + ->first(); + if (! $ticket) { + return; + } + + $stub = app(CareQueueProvisioner::class)->resourcesFor($organization, $moduleKey, (int) $visit->branch_id); + $point = collect($stub['points'] ?? []) + ->first(fn ($p) => ($p['kind'] ?? '') === 'stage' && ($p['stage'] ?? '') === $queuePoint); + + $ticket->update([ + 'metadata' => array_merge($ticket->metadata ?? [], [ + 'specialty_stage' => $stage, + 'queue_point' => $queuePoint, + 'point_external_key' => is_array($point) ? ($point['external_key'] ?? null) : null, + 'point_name' => is_array($point) ? ($point['name'] ?? null) : null, + ]), + ]); + } +} diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index 892745b..8eed965 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -118,10 +118,13 @@ return [ 'workspace_tabs' => [ 'overview' => 'Overview', 'odontogram' => 'Chart', + 'perio' => 'Perio', 'plan' => 'Plan', 'treat' => 'Treat', + 'lab' => 'Lab', 'notes' => 'Notes', 'imaging' => 'Imaging', + 'recalls' => 'Recalls', 'orders' => 'Orders', 'billing' => 'Billing', 'documents' => 'Documents', diff --git a/database/migrations/2026_07_18_180000_expand_care_dentistry_pms.php b/database/migrations/2026_07_18_180000_expand_care_dentistry_pms.php new file mode 100644 index 0000000..64eae25 --- /dev/null +++ b/database/migrations/2026_07_18_180000_expand_care_dentistry_pms.php @@ -0,0 +1,155 @@ +string('specialty_stage', 32)->nullable()->after('status')->index(); + }); + } + + if (Schema::hasTable('care_dental_charts') && ! Schema::hasColumn('care_dental_charts', 'dentition')) { + Schema::table('care_dental_charts', function (Blueprint $table) { + $table->string('dentition', 16)->default('adult')->after('notation'); + }); + } + + if (Schema::hasTable('care_dental_treatment_plans')) { + Schema::table('care_dental_treatment_plans', function (Blueprint $table) { + if (! Schema::hasColumn('care_dental_treatment_plans', 'rejected_at')) { + $table->timestamp('rejected_at')->nullable()->after('completed_at'); + } + if (! Schema::hasColumn('care_dental_treatment_plans', 'rejection_reason')) { + $table->string('rejection_reason')->nullable()->after('rejected_at'); + } + }); + } + + if (Schema::hasTable('care_dental_images') && ! Schema::hasColumn('care_dental_images', 'deleted_at')) { + Schema::table('care_dental_images', function (Blueprint $table) { + $table->softDeletes(); + }); + } + + if (! Schema::hasTable('care_dental_perio_exams')) { + Schema::create('care_dental_perio_exams', 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->date('exam_date')->nullable(); + $table->string('examiner')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['organization_id', 'patient_id'], 'care_den_perio_exams_org_pt_idx'); + }); + } + + if (! Schema::hasTable('care_dental_perio_sites')) { + Schema::create('care_dental_perio_sites', function (Blueprint $table) { + $table->id(); + $table->foreignId('perio_exam_id')->constrained('care_dental_perio_exams')->cascadeOnDelete(); + $table->string('tooth_code', 8); + $table->string('surface', 8); + $table->unsignedTinyInteger('probing_mm')->nullable(); + $table->boolean('bleeding')->default(false); + $table->boolean('plaque')->default(false); + $table->unsignedTinyInteger('recession_mm')->nullable(); + $table->timestamps(); + + $table->unique(['perio_exam_id', 'tooth_code', 'surface'], 'care_den_perio_sites_unique'); + }); + } + + if (! Schema::hasTable('care_dental_lab_cases')) { + Schema::create('care_dental_lab_cases', 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('plan_item_id')->nullable()->constrained('care_dental_plan_items')->nullOnDelete(); + $table->json('tooth_codes')->nullable(); + $table->string('case_type', 64); + $table->string('lab_name')->nullable(); + $table->string('status', 32)->default('draft')->index(); + $table->timestamp('ordered_at')->nullable(); + $table->date('due_at')->nullable(); + $table->timestamp('received_at')->nullable(); + $table->text('notes')->nullable(); + $table->string('created_by')->nullable(); + $table->timestamps(); + + $table->index(['organization_id', 'status'], 'care_den_lab_cases_org_status_idx'); + $table->index(['patient_id', 'status'], 'care_den_lab_cases_pt_status_idx'); + }); + } + + if (! Schema::hasTable('care_dental_recalls')) { + Schema::create('care_dental_recalls', 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->date('due_on'); + $table->string('reason')->nullable(); + $table->string('status', 32)->default('scheduled')->index(); + $table->foreignId('linked_visit_id')->nullable()->constrained('care_visits')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->string('created_by')->nullable(); + $table->timestamps(); + + $table->index(['organization_id', 'due_on'], 'care_den_recalls_org_due_idx'); + $table->index(['patient_id', 'status'], 'care_den_recalls_pt_status_idx'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('care_dental_recalls'); + Schema::dropIfExists('care_dental_lab_cases'); + Schema::dropIfExists('care_dental_perio_sites'); + Schema::dropIfExists('care_dental_perio_exams'); + + if (Schema::hasTable('care_dental_images') && Schema::hasColumn('care_dental_images', 'deleted_at')) { + Schema::table('care_dental_images', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } + + if (Schema::hasTable('care_dental_treatment_plans')) { + Schema::table('care_dental_treatment_plans', function (Blueprint $table) { + if (Schema::hasColumn('care_dental_treatment_plans', 'rejection_reason')) { + $table->dropColumn('rejection_reason'); + } + if (Schema::hasColumn('care_dental_treatment_plans', 'rejected_at')) { + $table->dropColumn('rejected_at'); + } + }); + } + + if (Schema::hasTable('care_dental_charts') && Schema::hasColumn('care_dental_charts', 'dentition')) { + Schema::table('care_dental_charts', function (Blueprint $table) { + $table->dropColumn('dentition'); + }); + } + + if (Schema::hasTable('care_visits') && Schema::hasColumn('care_visits', 'specialty_stage')) { + Schema::table('care_visits', function (Blueprint $table) { + $table->dropColumn('specialty_stage'); + }); + } + } +}; diff --git a/resources/views/care/specialty/dentistry/reports.blade.php b/resources/views/care/specialty/dentistry/reports.blade.php index cc9dadb..39adcf8 100644 --- a/resources/views/care/specialty/dentistry/reports.blade.php +++ b/resources/views/care/specialty/dentistry/reports.blade.php @@ -8,6 +8,18 @@ ← Back to queue +
+
+ + +
+
+ + +
+ +
+

Unfinished plan items

@@ -56,7 +68,21 @@
-

Imaging by modality (month)

+

Revenue by procedure (range)

+
    + @forelse ($report['revenue_by_code'] as $row) +
  • + {{ $row->description }} × {{ $row->total }} + {{ $currency }} {{ number_format($row->amount_minor / 100, 2) }} +
  • + @empty +
  • No dental procedure revenue in this range.
  • + @endforelse +
+
+ +
+

Imaging by modality

    @forelse ($report['imaging_by_modality'] as $row)
  • diff --git a/resources/views/care/specialty/dentistry/workspace-imaging.blade.php b/resources/views/care/specialty/dentistry/workspace-imaging.blade.php index 52df537..b178ee7 100644 --- a/resources/views/care/specialty/dentistry/workspace-imaging.blade.php +++ b/resources/views/care/specialty/dentistry/workspace-imaging.blade.php @@ -58,16 +58,24 @@ 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 +
    +
    +

    {{ $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 +
    +
    + @csrf + +
    +
@empty diff --git a/resources/views/care/specialty/dentistry/workspace-lab.blade.php b/resources/views/care/specialty/dentistry/workspace-lab.blade.php new file mode 100644 index 0000000..93eded7 --- /dev/null +++ b/resources/views/care/specialty/dentistry/workspace-lab.blade.php @@ -0,0 +1,83 @@ +
+

Lab cases

+

Track crowns, bridges, dentures, and other lab work.

+ +
    + @forelse ($dentalLabCases ?? [] as $case) +
  • +
    +
    +

    + {{ $dentalLabCaseTypes[$case->case_type] ?? $case->case_type }} + @if ($case->lab_name) · {{ $case->lab_name }} @endif +

    +

    + {{ $dentalLabCaseStatuses[$case->status] ?? $case->status }} + @if (! empty($case->tooth_codes)) · {{ implode(', ', $case->tooth_codes) }} @endif + @if ($case->due_at) · due {{ $case->due_at->format('d M Y') }} @endif +

    +
    + @if ($case->status !== 'cancelled' && $case->status !== 'fitted') +
    + @csrf + + +
    + @endif +
    +
  • + @empty +
  • No lab cases yet.
  • + @endforelse +
+ +
+ @csrf +

New lab case

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

Teeth

+
+ @foreach (array_merge($dentalRows['upper'] ?? [], $dentalRows['lower'] ?? []) as $code) + + @endforeach +
+
+
+ + +
+
+ +
+
diff --git a/resources/views/care/specialty/dentistry/workspace-odontogram.blade.php b/resources/views/care/specialty/dentistry/workspace-odontogram.blade.php index 3ef7f7a..319cb9e 100644 --- a/resources/views/care/specialty/dentistry/workspace-odontogram.blade.php +++ b/resources/views/care/specialty/dentistry/workspace-odontogram.blade.php @@ -1,16 +1,28 @@ @php $teethMap = $dentalTeethMap ?? []; - $selected = old('tooth_code', request('tooth', '16')); + $selected = old('tooth_code', request('tooth', ($dentalRows['upper'][0] ?? '16'))); $selectedData = $teethMap[$selected] ?? ['status' => 'present', 'surfaces' => [], 'conditions' => [], 'notes' => null]; + $dentition = $dentalChart->dentition ?? 'adult'; @endphp

Odontogram (FDI)

-

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

+

Click a tooth to chart status, surfaces, and conditions.

+
+
+
+ @csrf + + +
+ Print
- Print chart / plan
@@ -20,20 +32,27 @@
@foreach (($dentalRows[$rowKey] ?? []) as $code) @php - $t = $teethMap[$code] ?? ['status' => 'present', 'conditions' => []]; - $hasFinding = ($t['status'] ?? 'present') !== 'present' || ! empty($t['conditions']); + $t = $teethMap[$code] ?? ['status' => 'present', 'surfaces' => [], 'conditions' => []]; + $baseClass = \App\Services\Care\Dentistry\DentalCatalog::toothCssClass($t['status'] ?? 'present', $t['conditions'] ?? []); + $surfaces = $t['surfaces'] ?? []; @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, - ]) + class="inline-flex flex-col items-center justify-center rounded-lg px-1 py-1 text-xs font-semibold tabular-nums transition {{ $code === $selected ? 'ring-2 ring-indigo-500' : '' }} {{ $baseClass }}" title="Tooth {{ $code }}" > - {{ $code }} + {{ $code }} + @if (! empty($surfaces)) + + @foreach (['M','O','D','B','L','I'] as $s) + in_array($s, $surfaces, true), + 'bg-slate-300/60' => ! in_array($s, $surfaces, true), + ])> + @endforeach + + @endif @endforeach
@@ -41,19 +60,13 @@ @endforeach
-
+ @csrf -

{{ $selected }}

-
-

Surfaces

@@ -75,7 +87,6 @@ @endforeach
-

Conditions

@@ -88,12 +99,10 @@ @endforeach
-
-
diff --git a/resources/views/care/specialty/dentistry/workspace-overview.blade.php b/resources/views/care/specialty/dentistry/workspace-overview.blade.php index 1dd74ca..7272d3f 100644 --- a/resources/views/care/specialty/dentistry/workspace-overview.blade.php +++ b/resources/views/care/specialty/dentistry/workspace-overview.blade.php @@ -1,17 +1,56 @@ @php $plan = $dentalPlan; $openItems = $plan?->items?->whereIn('status', ['proposed', 'accepted']) ?? collect(); + $currentStage = $workspaceVisit->specialty_stage; + $stageFlow = [ + 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], + 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], + 'procedure' => ['next' => 'recovery', 'label' => 'Move to recovery'], + 'recovery' => ['next' => 'completed', 'label' => 'Complete visit'], + ]; @endphp

Overview

-

Visit summary and open treatment work.

+

Visit stage, summary, and open treatment work.

Dentistry reports
+
+

Visit stage

+

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

+
+ @foreach (['waiting', 'chair', 'procedure', 'recovery', 'completed'] as $stageCode) + + @csrf + + + + @endforeach + @if ($currentStage && isset($stageFlow[$currentStage])) +
+ @csrf + + +
+ @endif +
+
+
Visit status
diff --git a/resources/views/care/specialty/dentistry/workspace-perio.blade.php b/resources/views/care/specialty/dentistry/workspace-perio.blade.php new file mode 100644 index 0000000..ef8fbd7 --- /dev/null +++ b/resources/views/care/specialty/dentistry/workspace-perio.blade.php @@ -0,0 +1,69 @@ +@php + $exam = $dentalPerioExam; + $siteMap = []; + foreach ($exam?->sites ?? [] as $site) { + $siteMap[$site->tooth_code][$site->surface] = $site; + } + $teeth = array_merge($dentalRows['upper'] ?? [], $dentalRows['lower'] ?? []); + $probeTeeth = array_slice($teeth, 0, 8); +@endphp + +
+

Periodontal exam

+

Record probing depths, bleeding, and plaque by tooth surface.

+ + @if ($exam) +

Latest exam {{ $exam->exam_date?->format('d M Y') }} · {{ $exam->sites->count() }} sites

+ @endif + +
+ @csrf +
+ + + + + @foreach ($dentalPerioSurfaces ?? [] as $surface) + + + + @endforeach + + + + @foreach ($probeTeeth as $idx => $tooth) + + + @foreach ($dentalPerioSurfaces ?? [] as $sIdx => $surface) + @php + $existing = $siteMap[$tooth][$surface] ?? null; + $base = "sites.{$idx}_{$sIdx}"; + @endphp + + + + + + @endforeach + + @endforeach + +
Tooth{{ $surface }} mmBP
{{ $tooth }} + + + bleeding)> + + plaque)> +
+
+
+ + +
+ +
+
diff --git a/resources/views/care/specialty/dentistry/workspace-plan.blade.php b/resources/views/care/specialty/dentistry/workspace-plan.blade.php index 6ead14a..37bd4d4 100644 --- a/resources/views/care/specialty/dentistry/workspace-plan.blade.php +++ b/resources/views/care/specialty/dentistry/workspace-plan.blade.php @@ -1,5 +1,6 @@ @php $plan = $dentalPlan; + $history = ($dentalPlanHistory ?? collect())->filter(fn ($p) => ! $plan || $p->id !== $plan->id); @endphp
@@ -15,78 +16,132 @@ @endif

- @if ($plan && $plan->status === 'draft') -
- @csrf - -
+ @if ($plan) +
+ @if ($plan->status === 'draft') +
+ @csrf + +
+ @endif + @if (in_array($plan->status, ['draft', 'accepted'], true)) +
+ @csrf + + +
+ @endif + @if ($plan->status !== 'completed') +
+ @csrf + +
+ @endif +
@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) }}

      +
    • +
      +
      +

      + {{ $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) }}

      +
      + @if (! in_array($item->status, ['done', 'cancelled'], true)) +
      + @csrf + +
      + @endif
      - {{ number_format($item->estimate_minor / 100, 2) }} + @if (! in_array($item->status, ['done', 'cancelled'], true)) +
      + @csrf + + + + +
      + @endif
    • @empty
    • No plan items yet.
    • @endforelse
    -
    - @csrf -

    Add item

    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -

    Surfaces

    -
    - @foreach ($dentalSurfaces as $surface) - - @endforeach + @if (! $plan || ! in_array($plan->status, ['completed', 'rejected', 'cancelled'], true)) + + @csrf +

    Add item

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

    Surfaces

    +
    + @foreach ($dentalSurfaces as $surface) + + @endforeach +
    +
    +
    + +
    -
    - - -
    -
    - - + + + @endif + +@if ($history->isNotEmpty()) +
    +

    Plan history

    +
      + @foreach ($history as $past) +
    • + {{ $past->title ?: 'Treatment plan' }} · {{ ucfirst(str_replace('_', ' ', $past->status)) }} · {{ $past->items->count() }} items + {{ $past->created_at?->format('d M Y') }} +
    • + @endforeach +
    +
    +@endif diff --git a/resources/views/care/specialty/dentistry/workspace-recalls.blade.php b/resources/views/care/specialty/dentistry/workspace-recalls.blade.php new file mode 100644 index 0000000..8c758a0 --- /dev/null +++ b/resources/views/care/specialty/dentistry/workspace-recalls.blade.php @@ -0,0 +1,52 @@ +
    +

    Recalls

    +

    Schedule follow-ups and mark them complete when the patient returns.

    + +
      + @forelse ($dentalRecalls ?? [] as $recall) +
    • +
      +

      {{ $recall->reason ?: 'Recall' }}

      +

      + Due {{ $recall->due_on?->format('d M Y') }} · {{ ucfirst($recall->status) }} +

      +
      + @if (in_array($recall->status, ['scheduled', 'due'], true)) +
      +
      + @csrf + +
      +
      + @csrf + +
      +
      + @endif +
    • + @empty +
    • No recalls scheduled.
    • + @endforelse +
    + +
    + @csrf +

    Schedule recall

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    diff --git a/resources/views/care/specialty/partials/actions-menu.blade.php b/resources/views/care/specialty/partials/actions-menu.blade.php index 339283b..7ff41d5 100644 --- a/resources/views/care/specialty/partials/actions-menu.blade.php +++ b/resources/views/care/specialty/partials/actions-menu.blade.php @@ -22,8 +22,15 @@ && $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED; $chartTab = collect($workspaceTabs ?? []) ->keys() - ->first(fn ($key) => ! in_array($key, ['overview', 'timeline', 'plan', 'treat', 'notes', 'imaging', 'orders', 'billing', 'documents'], true)) + ->first(fn ($key) => ! in_array($key, ['overview', 'timeline', 'plan', 'treat', 'notes', 'imaging', 'orders', 'billing', 'documents', 'perio', 'lab', 'recalls'], true)) ?: 'odontogram'; + $stageFlow = [ + 'waiting' => ['next' => 'chair', 'label' => 'Seat at chair'], + 'chair' => ['next' => 'procedure', 'label' => 'Start procedure'], + 'procedure' => ['next' => 'recovery', 'label' => 'Move to recovery'], + 'recovery' => ['next' => 'completed', 'label' => 'Complete visit'], + ]; + $currentStage = $workspaceVisit?->specialty_stage; @endphp @if ($onWorkspace) @@ -44,6 +51,20 @@ @endif @if ($workspaceVisit) + @if ($moduleKey === 'dentistry' && $currentStage && isset($stageFlow[$currentStage])) +
    + @csrf + + +
    + @elseif ($moduleKey === 'dentistry' && ! $currentStage) +
    + @csrf + + +
    + @endif + @if ($canCompleteConsultation)