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.');
}
}