Complete Dentistry commercial suite with PMS depth.
Deploy Ladill Care / deploy (push) Successful in 31s
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DentalLabCase extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_ORDERED = 'ordered';
|
||||
|
||||
public const STATUS_SENT = 'sent';
|
||||
|
||||
public const STATUS_RECEIVED = 'received';
|
||||
|
||||
public const STATUS_FITTED = 'fitted';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $table = 'care_dental_lab_cases';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'organization_id', 'patient_id', 'visit_id', 'plan_item_id',
|
||||
'tooth_codes', 'case_type', 'lab_name', 'status',
|
||||
'ordered_at', 'due_at', 'received_at', 'notes', 'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tooth_codes' => '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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DentalPerioExam extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
protected $table = 'care_dental_perio_exams';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'organization_id', 'patient_id', 'visit_id',
|
||||
'exam_date', 'examiner', 'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'exam_date' => '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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DentalPerioSite extends Model
|
||||
{
|
||||
protected $table = 'care_dental_perio_sites';
|
||||
|
||||
protected $fillable = [
|
||||
'perio_exam_id', 'tooth_code', 'surface',
|
||||
'probing_mm', 'bleeding', 'plaque', 'recession_mm',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'bleeding' => 'boolean',
|
||||
'plaque' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function exam(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DentalPerioExam::class, 'perio_exam_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DentalRecall extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
public const STATUS_SCHEDULED = 'scheduled';
|
||||
|
||||
public const STATUS_DUE = 'due';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $table = 'care_dental_recalls';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'organization_id', 'patient_id',
|
||||
'due_on', 'reason', 'status', 'linked_visit_id', 'notes', 'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'due_on' => '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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, array<string, mixed>> $priorByKey
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
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<int|string>|null $departmentIds
|
||||
* @param \Illuminate\Support\Collection<string, array<string, mixed>> $priorByKey
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> */
|
||||
public static function adultToothCodes(): array
|
||||
{
|
||||
@@ -21,15 +27,52 @@ class DentalCatalog
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
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<string> */
|
||||
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<string>, lower: list<string>}
|
||||
*/
|
||||
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<string> */
|
||||
@@ -38,6 +81,22 @@ class DentalCatalog
|
||||
return ['M', 'O', 'D', 'B', 'L', 'I'];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function perioSurfaces(): array
|
||||
{
|
||||
return ['MB', 'B', 'DB', 'ML', 'L', 'DL'];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public static function dentitions(): array
|
||||
{
|
||||
return [
|
||||
self::DENTITION_ADULT => 'Adult',
|
||||
self::DENTITION_PRIMARY => 'Primary',
|
||||
self::DENTITION_MIXED => 'Mixed',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public static function conditions(): array
|
||||
{
|
||||
@@ -75,6 +134,32 @@ class DentalCatalog
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public static function labCaseTypes(): array
|
||||
{
|
||||
return [
|
||||
'crown' => 'Crown',
|
||||
'bridge' => 'Bridge',
|
||||
'denture' => 'Denture',
|
||||
'veneer' => 'Veneer',
|
||||
'ortho' => 'Ortho appliance',
|
||||
'other' => 'Other',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public static function labCaseStatuses(): array
|
||||
{
|
||||
return [
|
||||
'draft' => 'Draft',
|
||||
'ordered' => 'Ordered',
|
||||
'sent' => 'Sent to lab',
|
||||
'received' => 'Received',
|
||||
'fitted' => 'Fitted',
|
||||
'cancelled' => 'Cancelled',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array{label: string, amount_minor: int, type: string}> */
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>, conditions?: list<string>, notes?: ?string} $data
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care\Dentistry;
|
||||
|
||||
use App\Models\DentalLabCase;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Patient;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Care\AuditLogger;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DentalLabCaseService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, DentalLabCase>
|
||||
*/
|
||||
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<string>, 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care\Dentistry;
|
||||
|
||||
use App\Models\DentalPerioExam;
|
||||
use App\Models\DentalPerioSite;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Patient;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Care\AuditLogger;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DentalPerioService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, DentalPerioExam>
|
||||
*/
|
||||
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<array{tooth_code: string, surface: string, probing_mm?: ?int, bleeding?: bool, plaque?: bool, recession_mm?: ?int}> $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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care\Dentistry;
|
||||
|
||||
use App\Models\DentalRecall;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Patient;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Care\AuditLogger;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DentalRecallService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, DentalRecall>
|
||||
*/
|
||||
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<int, DentalRecall>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<int, DentalTreatmentPlan>
|
||||
*/
|
||||
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<string>, 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Appointment;
|
||||
use App\Models\CareQueueTicket;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Care\AuditLogger;
|
||||
|
||||
/**
|
||||
* Persist specialty visit stages (dentistry chair → recovery) and re-route queue tickets.
|
||||
*/
|
||||
class SpecialtyVisitStageService
|
||||
{
|
||||
public function __construct(
|
||||
protected SpecialtyShellService $shell,
|
||||
protected CareQueueBridge $queueBridge,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
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,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('care_visits') && ! Schema::hasColumn('care_visits', 'specialty_stage')) {
|
||||
Schema::table('care_visits', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -8,6 +8,18 @@
|
||||
<a href="{{ route('care.queue.index') }}" class="text-sm font-medium text-indigo-600">← Back to queue</a>
|
||||
</div>
|
||||
|
||||
<form method="GET" class="flex flex-wrap items-end gap-3 rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">From</label>
|
||||
<input type="date" name="from" value="{{ $report['from'] ?? '' }}" class="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">To</label>
|
||||
<input type="date" name="to" value="{{ $report['to'] ?? '' }}" class="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<button type="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white">Apply</button>
|
||||
</form>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Unfinished plan items</p>
|
||||
@@ -56,7 +68,21 @@
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Imaging by modality (month)</h2>
|
||||
<h2 class="text-sm font-semibold text-slate-900">Revenue by procedure (range)</h2>
|
||||
<ul class="mt-3 space-y-1 text-sm">
|
||||
@forelse ($report['revenue_by_code'] as $row)
|
||||
<li class="flex justify-between gap-3">
|
||||
<span>{{ $row->description }} × {{ $row->total }}</span>
|
||||
<span class="tabular-nums">{{ $currency }} {{ number_format($row->amount_minor / 100, 2) }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-slate-500">No dental procedure revenue in this range.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Imaging by modality</h2>
|
||||
<ul class="mt-3 space-y-1 text-sm">
|
||||
@forelse ($report['imaging_by_modality'] as $row)
|
||||
<li class="flex justify-between gap-3">
|
||||
|
||||
@@ -58,16 +58,24 @@
|
||||
<a href="{{ $url }}" target="_blank" class="flex h-32 items-center justify-center text-sm font-medium text-indigo-600">Open file</a>
|
||||
@endif
|
||||
<div class="px-3 py-2 text-xs">
|
||||
<p class="font-semibold text-slate-800">{{ $dentalModalities[$image->modality] ?? $image->modality }}</p>
|
||||
<p class="text-slate-500">
|
||||
{{ $image->captured_at?->format('d M Y H:i') }}
|
||||
@if (! empty($image->tooth_codes))
|
||||
· {{ implode(', ', $image->tooth_codes) }}
|
||||
@endif
|
||||
</p>
|
||||
@if ($image->caption)
|
||||
<p class="mt-0.5 text-slate-600">{{ $image->caption }}</p>
|
||||
@endif
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-800">{{ $dentalModalities[$image->modality] ?? $image->modality }}</p>
|
||||
<p class="text-slate-500">
|
||||
{{ $image->captured_at?->format('d M Y H:i') }}
|
||||
@if (! empty($image->tooth_codes))
|
||||
· {{ implode(', ', $image->tooth_codes) }}
|
||||
@endif
|
||||
</p>
|
||||
@if ($image->caption)
|
||||
<p class="mt-0.5 text-slate-600">{{ $image->caption }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.images.void', [$workspaceVisit, $image]) }}" onsubmit="return confirm('Void this image?')">
|
||||
@csrf
|
||||
<button type="submit" class="text-rose-600 hover:underline">Void</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Lab cases</h3>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Track crowns, bridges, dentures, and other lab work.</p>
|
||||
|
||||
<ul class="mt-4 space-y-2 text-sm">
|
||||
@forelse ($dentalLabCases ?? [] as $case)
|
||||
<li class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="font-medium text-slate-800">
|
||||
{{ $dentalLabCaseTypes[$case->case_type] ?? $case->case_type }}
|
||||
@if ($case->lab_name) <span class="text-slate-500">· {{ $case->lab_name }}</span> @endif
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
{{ $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
|
||||
</p>
|
||||
</div>
|
||||
@if ($case->status !== 'cancelled' && $case->status !== 'fitted')
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.lab-cases.status', [$workspaceVisit, $case]) }}" class="flex gap-1">
|
||||
@csrf
|
||||
<select name="status" class="rounded-lg border border-slate-200 px-2 py-1 text-xs">
|
||||
@foreach ($dentalLabCaseStatuses as $value => $label)
|
||||
<option value="{{ $value }}" @selected($case->status === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit" class="rounded-lg bg-white px-2 py-1 text-xs font-semibold text-indigo-700 ring-1 ring-indigo-200">Update</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-slate-500">No lab cases yet.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.lab-cases', $workspaceVisit) }}" class="mt-6 space-y-3 border-t border-slate-100 pt-4">
|
||||
@csrf
|
||||
<h4 class="text-sm font-semibold text-slate-900">New lab case</h4>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Type</label>
|
||||
<select name="case_type" required class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
@foreach ($dentalLabCaseTypes ?? [] as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Lab name</label>
|
||||
<input type="text" name="lab_name" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Due date</label>
|
||||
<input type="date" name="due_at" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Status</label>
|
||||
<select name="status" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="ordered">Ordered</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<p class="text-xs font-medium text-slate-600">Teeth</p>
|
||||
<div class="mt-2 flex max-h-24 flex-wrap gap-2 overflow-y-auto">
|
||||
@foreach (array_merge($dentalRows['upper'] ?? [], $dentalRows['lower'] ?? []) as $code)
|
||||
<label class="inline-flex items-center gap-1 rounded border border-slate-200 px-1.5 py-0.5 text-xs">
|
||||
<input type="checkbox" name="tooth_codes[]" value="{{ $code }}" class="rounded border-slate-300 text-indigo-600">
|
||||
{{ $code }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-slate-600">Notes</label>
|
||||
<input type="text" name="notes" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Create lab case</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -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
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Odontogram (FDI)</h3>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Click a tooth to chart status, surfaces, and conditions. Changes are saved to the patient chart.</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Click a tooth to chart status, surfaces, and conditions.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.dentition', $workspaceVisit) }}" class="flex items-center gap-2">
|
||||
@csrf
|
||||
<label class="text-xs font-medium text-slate-600">Dentition</label>
|
||||
<select name="dentition" onchange="this.form.submit()" class="rounded-lg border border-slate-200 px-2 py-1.5 text-sm">
|
||||
@foreach ($dentalDentitions ?? [] as $value => $label)
|
||||
<option value="{{ $value }}" @selected($dentition === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</form>
|
||||
<a href="{{ route('care.specialty.dentistry.print', $workspaceVisit) }}" target="_blank" class="text-sm font-medium text-indigo-600">Print</a>
|
||||
</div>
|
||||
<a href="{{ route('care.specialty.dentistry.print', $workspaceVisit) }}" target="_blank" class="text-sm font-medium text-indigo-600">Print chart / plan</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@@ -20,20 +32,27 @@
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@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
|
||||
<a
|
||||
href="{{ route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $workspaceVisit, 'tab' => 'odontogram', 'tooth' => $code]) }}"
|
||||
@class([
|
||||
'inline-flex h-10 w-9 items-center justify-center rounded-lg border text-xs font-semibold tabular-nums transition',
|
||||
'border-indigo-400 bg-indigo-50 text-indigo-900 ring-2 ring-indigo-400' => $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 }}
|
||||
<span>{{ $code }}</span>
|
||||
@if (! empty($surfaces))
|
||||
<span class="mt-0.5 flex gap-0.5">
|
||||
@foreach (['M','O','D','B','L','I'] as $s)
|
||||
<span @class([
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
'bg-indigo-600' => in_array($s, $surfaces, true),
|
||||
'bg-slate-300/60' => ! in_array($s, $surfaces, true),
|
||||
])></span>
|
||||
@endforeach
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -41,19 +60,13 @@
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('care.specialty.dentistry.tooth', $workspaceVisit) }}"
|
||||
class="mt-6 grid gap-4 border-t border-slate-100 pt-4 sm:grid-cols-2"
|
||||
>
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.tooth', $workspaceVisit) }}" class="mt-6 grid gap-4 border-t border-slate-100 pt-4 sm:grid-cols-2">
|
||||
@csrf
|
||||
<input type="hidden" name="tooth_code" value="{{ $selected }}">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Selected tooth</label>
|
||||
<p class="mt-1 text-lg font-semibold tabular-nums text-slate-900">{{ $selected }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Status</label>
|
||||
<select name="status" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
@@ -62,7 +75,6 @@
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<p class="text-xs font-medium text-slate-600">Surfaces</p>
|
||||
<div class="mt-2 flex flex-wrap gap-3">
|
||||
@@ -75,7 +87,6 @@
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<p class="text-xs font-medium text-slate-600">Conditions</p>
|
||||
<div class="mt-2 flex flex-wrap gap-3">
|
||||
@@ -88,12 +99,10 @@
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-slate-600">Notes</label>
|
||||
<textarea name="notes" rows="2" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">{{ $selectedData['notes'] ?? '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<button type="submit" class="btn-primary">Save tooth</button>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Overview</h3>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Visit summary and open treatment work.</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Visit stage, summary, and open treatment work.</p>
|
||||
</div>
|
||||
<a href="{{ route('care.specialty.dentistry.reports') }}" class="text-sm font-medium text-indigo-600">Dentistry reports</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Visit stage</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-900">
|
||||
{{ $currentStage ? ucfirst(str_replace('_', ' ', $currentStage)) : 'Not set' }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
@foreach (['waiting', 'chair', 'procedure', 'recovery', 'completed'] as $stageCode)
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}">
|
||||
@csrf
|
||||
<input type="hidden" name="stage" value="{{ $stageCode }}">
|
||||
<button type="submit"
|
||||
@class([
|
||||
'rounded-lg px-2.5 py-1 text-xs font-semibold',
|
||||
'bg-indigo-600 text-white' => $currentStage === $stageCode,
|
||||
'border border-slate-200 bg-white text-slate-700 hover:bg-slate-50' => $currentStage !== $stageCode,
|
||||
])>
|
||||
{{ ucfirst($stageCode) }}
|
||||
</button>
|
||||
</form>
|
||||
@endforeach
|
||||
@if ($currentStage && isset($stageFlow[$currentStage]))
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}">
|
||||
@csrf
|
||||
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
|
||||
<button type="submit" class="rounded-lg bg-emerald-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-emerald-700">
|
||||
{{ $stageFlow[$currentStage]['label'] }} →
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-slate-500">Visit status</dt>
|
||||
|
||||
@@ -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
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Periodontal exam</h3>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Record probing depths, bleeding, and plaque by tooth surface.</p>
|
||||
|
||||
@if ($exam)
|
||||
<p class="mt-3 text-xs text-slate-500">Latest exam {{ $exam->exam_date?->format('d M Y') }} · {{ $exam->sites->count() }} sites</p>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.perio', $workspaceVisit) }}" class="mt-4 space-y-4">
|
||||
@csrf
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 text-slate-500">
|
||||
<th class="py-2 pr-2">Tooth</th>
|
||||
@foreach ($dentalPerioSurfaces ?? [] as $surface)
|
||||
<th class="px-1 py-2">{{ $surface }} mm</th>
|
||||
<th class="px-1 py-2">B</th>
|
||||
<th class="px-1 py-2">P</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($probeTeeth as $idx => $tooth)
|
||||
<tr class="border-b border-slate-100">
|
||||
<td class="py-2 pr-2 font-semibold tabular-nums">{{ $tooth }}</td>
|
||||
@foreach ($dentalPerioSurfaces ?? [] as $sIdx => $surface)
|
||||
@php
|
||||
$existing = $siteMap[$tooth][$surface] ?? null;
|
||||
$base = "sites.{$idx}_{$sIdx}";
|
||||
@endphp
|
||||
<input type="hidden" name="sites[{{ $idx }}_{{ $sIdx }}][tooth_code]" value="{{ $tooth }}">
|
||||
<input type="hidden" name="sites[{{ $idx }}_{{ $sIdx }}][surface]" value="{{ $surface }}">
|
||||
<td class="px-1 py-1">
|
||||
<input type="number" min="0" max="15" name="sites[{{ $idx }}_{{ $sIdx }}][probing_mm]"
|
||||
value="{{ $existing?->probing_mm }}"
|
||||
class="w-12 rounded border border-slate-200 px-1 py-1 tabular-nums">
|
||||
</td>
|
||||
<td class="px-1 py-1">
|
||||
<input type="checkbox" name="sites[{{ $idx }}_{{ $sIdx }}][bleeding]" value="1" class="rounded border-slate-300"
|
||||
@checked($existing?->bleeding)>
|
||||
</td>
|
||||
<td class="px-1 py-1">
|
||||
<input type="checkbox" name="sites[{{ $idx }}_{{ $sIdx }}][plaque]" value="1" class="rounded border-slate-300"
|
||||
@checked($existing?->plaque)>
|
||||
</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Notes</label>
|
||||
<textarea name="notes" rows="2" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">{{ $exam?->notes }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Save perio exam</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -1,5 +1,6 @@
|
||||
@php
|
||||
$plan = $dentalPlan;
|
||||
$history = ($dentalPlanHistory ?? collect())->filter(fn ($p) => ! $plan || $p->id !== $plan->id);
|
||||
@endphp
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
@@ -15,78 +16,132 @@
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@if ($plan && $plan->status === 'draft')
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.plan.accept', $workspaceVisit) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-sm font-medium text-emerald-800 hover:bg-emerald-100">Accept plan</button>
|
||||
</form>
|
||||
@if ($plan)
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@if ($plan->status === 'draft')
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.plan.accept', $workspaceVisit) }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-sm font-medium text-emerald-800 hover:bg-emerald-100">Accept</button>
|
||||
</form>
|
||||
@endif
|
||||
@if (in_array($plan->status, ['draft', 'accepted'], true))
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.plan.reject', $workspaceVisit) }}" class="flex gap-1">
|
||||
@csrf
|
||||
<input type="text" name="rejection_reason" placeholder="Reject reason" class="rounded-lg border border-slate-200 px-2 py-1 text-sm">
|
||||
<button type="submit" class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-1.5 text-sm font-medium text-amber-900">Reject</button>
|
||||
</form>
|
||||
@endif
|
||||
@if ($plan->status !== 'completed')
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.plan.cancel', $workspaceVisit) }}" onsubmit="return confirm('Cancel this plan?')">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700">Cancel plan</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<ul class="mt-4 space-y-2 text-sm">
|
||||
<ul class="mt-4 space-y-3 text-sm">
|
||||
@forelse ($plan?->items ?? [] as $item)
|
||||
<li class="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<div>
|
||||
<p class="font-medium text-slate-800">
|
||||
{{ $item->label }}
|
||||
@if ($item->tooth_code)
|
||||
<span class="text-slate-500">· {{ $item->tooth_code }}</span>
|
||||
@endif
|
||||
@if (! empty($item->surfaces))
|
||||
<span class="text-slate-400">({{ implode('', $item->surfaces) }})</span>
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">Priority {{ $item->priority }} · {{ ucfirst($item->status) }}</p>
|
||||
<li class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="font-medium text-slate-800">
|
||||
{{ $item->label }}
|
||||
@if ($item->tooth_code)<span class="text-slate-500">· {{ $item->tooth_code }}</span>@endif
|
||||
@if (! empty($item->surfaces))<span class="text-slate-400">({{ implode('', $item->surfaces) }})</span>@endif
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">Priority {{ $item->priority }} · {{ ucfirst($item->status) }} · {{ number_format($item->estimate_minor / 100, 2) }}</p>
|
||||
</div>
|
||||
@if (! in_array($item->status, ['done', 'cancelled'], true))
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.plan-items.cancel', [$workspaceVisit, $item]) }}" onsubmit="return confirm('Cancel this item?')">
|
||||
@csrf
|
||||
<button type="submit" class="text-xs font-medium text-rose-600">Cancel item</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
<span class="tabular-nums text-slate-600">{{ number_format($item->estimate_minor / 100, 2) }}</span>
|
||||
@if (! in_array($item->status, ['done', 'cancelled'], true))
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.plan-items.update', [$workspaceVisit, $item]) }}" class="mt-3 grid gap-2 border-t border-slate-200/70 pt-3 sm:grid-cols-4">
|
||||
@csrf
|
||||
<select name="procedure_code" class="rounded-lg border border-slate-200 px-2 py-1.5 text-xs">
|
||||
@foreach ($dentalCatalog as $service)
|
||||
<option value="{{ $service['code'] }}" @selected($item->procedure_code === $service['code'])>{{ $service['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select name="tooth_code" class="rounded-lg border border-slate-200 px-2 py-1.5 text-xs">
|
||||
<option value="">Tooth</option>
|
||||
@foreach (array_merge($dentalRows['upper'] ?? [], $dentalRows['lower'] ?? []) as $code)
|
||||
<option value="{{ $code }}" @selected($item->tooth_code === $code)>{{ $code }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="number" name="priority" value="{{ $item->priority }}" min="1" max="100" class="rounded-lg border border-slate-200 px-2 py-1.5 text-xs">
|
||||
<button type="submit" class="rounded-lg bg-white px-2 py-1.5 text-xs font-semibold text-indigo-700 ring-1 ring-indigo-200">Save</button>
|
||||
</form>
|
||||
@endif
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-slate-500">No plan items yet.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.plan-items', $workspaceVisit) }}" class="mt-6 space-y-3 border-t border-slate-100 pt-4">
|
||||
@csrf
|
||||
<h4 class="text-sm font-semibold text-slate-900">Add item</h4>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Procedure</label>
|
||||
<select name="procedure_code" required class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
<option value="">Select…</option>
|
||||
@foreach ($dentalCatalog as $service)
|
||||
<option value="{{ $service['code'] }}">{{ $service['label'] }} — {{ number_format(((int) $service['amount_minor']) / 100, 2) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Tooth (FDI)</label>
|
||||
<select name="tooth_code" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
<option value="">—</option>
|
||||
@foreach (array_merge($dentalRows['upper'] ?? [], $dentalRows['lower'] ?? []) as $code)
|
||||
<option value="{{ $code }}">{{ $code }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Priority (1 = highest)</label>
|
||||
<input type="number" name="priority" value="50" min="1" max="100" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<p class="text-xs font-medium text-slate-600">Surfaces</p>
|
||||
<div class="mt-2 flex flex-wrap gap-3">
|
||||
@foreach ($dentalSurfaces as $surface)
|
||||
<label class="inline-flex items-center gap-1.5 text-sm">
|
||||
<input type="checkbox" name="surfaces[]" value="{{ $surface }}" class="rounded border-slate-300 text-indigo-600">
|
||||
{{ $surface }}
|
||||
</label>
|
||||
@endforeach
|
||||
@if (! $plan || ! in_array($plan->status, ['completed', 'rejected', 'cancelled'], true))
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.plan-items', $workspaceVisit) }}" class="mt-6 space-y-3 border-t border-slate-100 pt-4">
|
||||
@csrf
|
||||
<h4 class="text-sm font-semibold text-slate-900">Add item</h4>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Procedure</label>
|
||||
<select name="procedure_code" required class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
<option value="">Select…</option>
|
||||
@foreach ($dentalCatalog as $service)
|
||||
<option value="{{ $service['code'] }}">{{ $service['label'] }} — {{ number_format(((int) $service['amount_minor']) / 100, 2) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Tooth (FDI)</label>
|
||||
<select name="tooth_code" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
<option value="">—</option>
|
||||
@foreach (array_merge($dentalRows['upper'] ?? [], $dentalRows['lower'] ?? []) as $code)
|
||||
<option value="{{ $code }}">{{ $code }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Priority (1 = highest)</label>
|
||||
<input type="number" name="priority" value="50" min="1" max="100" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<p class="text-xs font-medium text-slate-600">Surfaces</p>
|
||||
<div class="mt-2 flex flex-wrap gap-3">
|
||||
@foreach ($dentalSurfaces as $surface)
|
||||
<label class="inline-flex items-center gap-1.5 text-sm">
|
||||
<input type="checkbox" name="surfaces[]" value="{{ $surface }}" class="rounded border-slate-300 text-indigo-600">
|
||||
{{ $surface }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-slate-600">Notes</label>
|
||||
<input type="text" name="notes" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-slate-600">Notes</label>
|
||||
<input type="text" name="notes" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Add to plan</button>
|
||||
</form>
|
||||
<button type="submit" class="btn-primary">Add to plan</button>
|
||||
</form>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
@if ($history->isNotEmpty())
|
||||
<section class="mt-4 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Plan history</h3>
|
||||
<ul class="mt-3 space-y-2 text-sm">
|
||||
@foreach ($history as $past)
|
||||
<li class="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-100 px-3 py-2">
|
||||
<span>{{ $past->title ?: 'Treatment plan' }} · {{ ucfirst(str_replace('_', ' ', $past->status)) }} · {{ $past->items->count() }} items</span>
|
||||
<span class="text-xs text-slate-500">{{ $past->created_at?->format('d M Y') }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Recalls</h3>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Schedule follow-ups and mark them complete when the patient returns.</p>
|
||||
|
||||
<ul class="mt-4 space-y-2 text-sm">
|
||||
@forelse ($dentalRecalls ?? [] as $recall)
|
||||
<li class="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<div>
|
||||
<p class="font-medium text-slate-800">{{ $recall->reason ?: 'Recall' }}</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
Due {{ $recall->due_on?->format('d M Y') }} · {{ ucfirst($recall->status) }}
|
||||
</p>
|
||||
</div>
|
||||
@if (in_array($recall->status, ['scheduled', 'due'], true))
|
||||
<div class="flex gap-2">
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.recalls.complete', [$workspaceVisit, $recall]) }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-xs font-medium text-emerald-700">Complete</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.recalls.cancel', [$workspaceVisit, $recall]) }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-xs font-medium text-rose-600">Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-slate-500">No recalls scheduled.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.recalls', $workspaceVisit) }}" class="mt-6 space-y-3 border-t border-slate-100 pt-4">
|
||||
@csrf
|
||||
<h4 class="text-sm font-semibold text-slate-900">Schedule recall</h4>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Due date</label>
|
||||
<input type="date" name="due_on" required class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm"
|
||||
value="{{ now()->addMonths(6)->toDateString() }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600">Reason</label>
|
||||
<input type="text" name="reason" placeholder="e.g. 6-month check" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-slate-600">Notes</label>
|
||||
<input type="text" name="notes" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Schedule recall</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -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]))
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}" class="w-full" @click.stop>
|
||||
@csrf
|
||||
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
|
||||
<button type="submit" class="{{ $btnAccent }}">{{ $stageFlow[$currentStage]['label'] }}</button>
|
||||
</form>
|
||||
@elseif ($moduleKey === 'dentistry' && ! $currentStage)
|
||||
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}" class="w-full" @click.stop>
|
||||
@csrf
|
||||
<input type="hidden" name="stage" value="chair">
|
||||
<button type="submit" class="{{ $btnAccent }}">Seat at chair</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if ($canCompleteConsultation)
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="space-y-4">
|
||||
@if ($activeTab === 'timeline')
|
||||
@include('care.partials.patient-timeline', ['events' => $timeline ?? []])
|
||||
@elseif ($moduleKey === 'dentistry' && in_array($activeTab, ['odontogram', 'plan', 'treat', 'notes', 'imaging', 'overview'], true))
|
||||
@elseif ($moduleKey === 'dentistry' && in_array($activeTab, ['odontogram', 'plan', 'treat', 'notes', 'imaging', 'overview', 'perio', 'lab', 'recalls'], true))
|
||||
@include('care.specialty.dentistry.workspace-'.$activeTab)
|
||||
@elseif ($activeTab === 'billing')
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
|
||||
@@ -119,11 +119,24 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
Route::post('/specialty/{module}/workspace/{visit}/services', [SpecialtyModuleController::class, 'addService'])->name('care.specialty.services.add');
|
||||
Route::post('/specialty/{module}/workspace/{visit}/start-consultation', [SpecialtyModuleController::class, 'startConsultation'])->name('care.specialty.consultation.start');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/tooth', [DentistryWorkspaceController::class, 'updateTooth'])->name('care.specialty.dentistry.tooth');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/dentition', [DentistryWorkspaceController::class, 'setDentition'])->name('care.specialty.dentistry.dentition');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/plan-items', [DentistryWorkspaceController::class, 'addPlanItem'])->name('care.specialty.dentistry.plan-items');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/plan-items/{item}', [DentistryWorkspaceController::class, 'updatePlanItem'])->name('care.specialty.dentistry.plan-items.update');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/plan-items/{item}/cancel', [DentistryWorkspaceController::class, 'cancelPlanItem'])->name('care.specialty.dentistry.plan-items.cancel');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/plan/accept', [DentistryWorkspaceController::class, 'acceptPlan'])->name('care.specialty.dentistry.plan.accept');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/plan/reject', [DentistryWorkspaceController::class, 'rejectPlan'])->name('care.specialty.dentistry.plan.reject');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/plan/cancel', [DentistryWorkspaceController::class, 'cancelPlan'])->name('care.specialty.dentistry.plan.cancel');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/procedures', [DentistryWorkspaceController::class, 'completeProcedure'])->name('care.specialty.dentistry.procedures');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/notes', [DentistryWorkspaceController::class, 'saveNotes'])->name('care.specialty.dentistry.notes');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/images', [DentistryWorkspaceController::class, 'uploadImage'])->name('care.specialty.dentistry.images');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/images/{image}/void', [DentistryWorkspaceController::class, 'voidImage'])->name('care.specialty.dentistry.images.void');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/stage', [DentistryWorkspaceController::class, 'setStage'])->name('care.specialty.dentistry.stage');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/perio', [DentistryWorkspaceController::class, 'savePerio'])->name('care.specialty.dentistry.perio');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/lab-cases', [DentistryWorkspaceController::class, 'createLabCase'])->name('care.specialty.dentistry.lab-cases');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/lab-cases/{labCase}/status', [DentistryWorkspaceController::class, 'transitionLabCase'])->name('care.specialty.dentistry.lab-cases.status');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/recalls', [DentistryWorkspaceController::class, 'scheduleRecall'])->name('care.specialty.dentistry.recalls');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/recalls/{recall}/complete', [DentistryWorkspaceController::class, 'completeRecall'])->name('care.specialty.dentistry.recalls.complete');
|
||||
Route::post('/specialty/dentistry/workspace/{visit}/recalls/{recall}/cancel', [DentistryWorkspaceController::class, 'cancelRecall'])->name('care.specialty.dentistry.recalls.cancel');
|
||||
Route::get('/specialty/dentistry/workspace/{visit}/print', [DentistryWorkspaceController::class, 'printChart'])->name('care.specialty.dentistry.print');
|
||||
Route::get('/specialty/{module}', [SpecialtyModuleController::class, 'show'])->name('care.specialty.show');
|
||||
Route::post('/specialty/{module}/call-next', [SpecialtyModuleController::class, 'callNext'])->name('care.specialty.call-next');
|
||||
|
||||
@@ -266,4 +266,131 @@ class CareDentistrySuiteTest extends TestCase
|
||||
->assertSee('Odontogram (FDI)')
|
||||
->assertSee('Afia Darko');
|
||||
}
|
||||
|
||||
public function test_plan_item_cancel_and_reject(): void
|
||||
{
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.plan-items', $this->visit), [
|
||||
'procedure_code' => 'den.fill',
|
||||
'tooth_code' => '16',
|
||||
'priority' => 10,
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$plan = DentalTreatmentPlan::query()->where('patient_id', $this->patient->id)->firstOrFail();
|
||||
$item = $plan->items()->firstOrFail();
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.plan-items.cancel', [$this->visit, $item]))
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertSame(DentalPlanItem::STATUS_CANCELLED, $item->fresh()->status);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.plan-items', $this->visit), [
|
||||
'procedure_code' => 'den.cleaning',
|
||||
'priority' => 20,
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.plan.reject', $this->visit), [
|
||||
'rejection_reason' => 'Patient declined',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertSame(DentalTreatmentPlan::STATUS_REJECTED, $plan->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_visit_stage_advance_and_primary_dentition(): void
|
||||
{
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.stage', $this->visit), [
|
||||
'stage' => 'chair',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertSame('chair', $this->visit->fresh()->specialty_stage);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.dentition', $this->visit), [
|
||||
'dentition' => 'primary',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$chart = DentalChart::query()->where('patient_id', $this->patient->id)->firstOrFail();
|
||||
$this->assertSame('primary', $chart->dentition);
|
||||
$this->assertTrue($chart->teeth()->where('tooth_code', '55')->exists());
|
||||
}
|
||||
|
||||
public function test_imaging_void_perio_lab_and_recall(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.images', $this->visit), [
|
||||
'attachment' => UploadedFile::fake()->image('xray.jpg'),
|
||||
'modality' => 'bw',
|
||||
'tooth_codes' => ['16'],
|
||||
'add_fee' => 0,
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$image = \App\Models\DentalImage::query()->where('visit_id', $this->visit->id)->firstOrFail();
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.images.void', [$this->visit, $image]))
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertSoftDeleted('care_dental_images', ['id' => $image->id]);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.perio', $this->visit), [
|
||||
'sites' => [
|
||||
['tooth_code' => '16', 'surface' => 'B', 'probing_mm' => 4, 'bleeding' => 1],
|
||||
],
|
||||
'notes' => 'Perio demo',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('care_dental_perio_exams', [
|
||||
'patient_id' => $this->patient->id,
|
||||
'visit_id' => $this->visit->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.lab-cases', $this->visit), [
|
||||
'case_type' => 'crown',
|
||||
'lab_name' => 'City Lab',
|
||||
'tooth_codes' => ['16'],
|
||||
'status' => 'ordered',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$case = \App\Models\DentalLabCase::query()->where('patient_id', $this->patient->id)->firstOrFail();
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.lab-cases.status', [$this->visit, $case]), [
|
||||
'status' => 'received',
|
||||
])
|
||||
->assertRedirect();
|
||||
$this->assertSame('received', $case->fresh()->status);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.recalls', $this->visit), [
|
||||
'due_on' => now()->addMonths(3)->toDateString(),
|
||||
'reason' => 'Check-up',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$recall = \App\Models\DentalRecall::query()->where('patient_id', $this->patient->id)->firstOrFail();
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.dentistry.recalls.complete', [$this->visit, $recall]))
|
||||
->assertRedirect();
|
||||
$this->assertSame('completed', $recall->fresh()->status);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->get(route('care.specialty.dentistry.reports'))
|
||||
->assertOk()
|
||||
->assertSee('Revenue by procedure');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ class CareSpecialtyClinicalTest extends TestCase
|
||||
->assertSee('ABC compromise');
|
||||
}
|
||||
|
||||
public function test_blood_bank_request_and_dentistry_odontogram_save(): void
|
||||
public function test_blood_bank_request_save(): void
|
||||
{
|
||||
[$bbVisit] = $this->activateWithVisit('blood_bank', 'blood_bank');
|
||||
|
||||
@@ -188,68 +188,6 @@ class CareSpecialtyClinicalTest extends TestCase
|
||||
'module_key' => 'blood_bank',
|
||||
'record_type' => 'blood_request',
|
||||
]);
|
||||
|
||||
app(SpecialtyModuleService::class)->activate(
|
||||
$this->organization->fresh(),
|
||||
$this->owner->public_id,
|
||||
'dentistry',
|
||||
);
|
||||
|
||||
$dentalDept = Department::query()
|
||||
->where('branch_id', $this->branch->id)
|
||||
->where('type', 'dental')
|
||||
->firstOrFail();
|
||||
|
||||
$patient = Patient::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-CL-2',
|
||||
'first_name' => 'Ama',
|
||||
'last_name' => 'Boateng',
|
||||
'gender' => 'female',
|
||||
'date_of_birth' => '1990-01-01',
|
||||
]);
|
||||
|
||||
$denVisit = Visit::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'status' => Visit::STATUS_OPEN,
|
||||
'checked_in_at' => now(),
|
||||
]);
|
||||
|
||||
Appointment::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_id' => $patient->id,
|
||||
'department_id' => $dentalDept->id,
|
||||
'visit_id' => $denVisit->id,
|
||||
'type' => Appointment::TYPE_WALK_IN,
|
||||
'status' => Appointment::STATUS_WAITING,
|
||||
'scheduled_at' => now(),
|
||||
'waiting_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->post(route('care.specialty.clinical.save', ['module' => 'dentistry', 'visit' => $denVisit]), [
|
||||
'tab' => 'odontogram',
|
||||
'payload' => [
|
||||
'teeth_affected' => '16, 26',
|
||||
'findings' => 'Caries on occlusal surfaces',
|
||||
'planned_procedures' => 'Fillings',
|
||||
'anesthesia' => 'Local',
|
||||
],
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('care_specialty_clinical_records', [
|
||||
'visit_id' => $denVisit->id,
|
||||
'module_key' => 'dentistry',
|
||||
'record_type' => 'odontogram',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_ophthalmology_uses_eye_exam_form_not_generic_notes(): void
|
||||
|
||||
Reference in New Issue
Block a user