Complete Dentistry commercial suite with PMS depth.
Deploy Ladill Care / deploy (push) Successful in 31s

Add plan lifecycle, visit stages with chair/recovery points, primary dentition charting, imaging void, revenue reports, perio exams, lab cases, and recalls.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 17:11:15 +00:00
co-authored by Cursor
parent e227f8705f
commit ce782382c5
37 changed files with 2458 additions and 204 deletions
@@ -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.');
}
}
@@ -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,