Ship commercial Dentistry suite with odontogram and treatment plans.
Deploy Ladill Care / deploy (push) Failing after 36s
Deploy Ladill Care / deploy (push) Failing after 36s
Replace placeholder clinical forms with patient-level FDI charting, multi-visit plans, procedure-to-bill linking, imaging, reports, and demo seed data. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
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\DentalProcedureService;
|
||||
use App\Services\Care\Dentistry\DentalTreatmentPlanService;
|
||||
use App\Services\Care\Dentistry\DentalVisitNoteService;
|
||||
use App\Services\Care\SpecialtyModuleService;
|
||||
use App\Services\Care\SpecialtyShellService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class DentistryWorkspaceController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
protected function assertDentistryAccess(Request $request, SpecialtyModuleService $modules): void
|
||||
{
|
||||
$organization = $this->organization($request);
|
||||
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'dentistry'), 403);
|
||||
}
|
||||
|
||||
protected function assertVisit(Request $request, Visit $visit): void
|
||||
{
|
||||
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
|
||||
$this->authorizeBranch($request, (int) $visit->branch_id);
|
||||
}
|
||||
|
||||
public function updateTooth(
|
||||
Request $request,
|
||||
Visit $visit,
|
||||
SpecialtyModuleService $modules,
|
||||
DentalChartService $charts,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
'tooth_code' => ['required', 'string', 'max:8'],
|
||||
'status' => ['required', 'string', 'in:present,missing,extracted,implant'],
|
||||
'surfaces' => ['nullable', 'array'],
|
||||
'surfaces.*' => ['string', 'max:2'],
|
||||
'conditions' => ['nullable', 'array'],
|
||||
'conditions.*' => ['string', 'max:32'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
$visit->loadMissing('patient');
|
||||
$chart = $charts->chartForPatient($organization, $visit->patient, $owner);
|
||||
|
||||
try {
|
||||
$charts->updateTooth(
|
||||
$chart,
|
||||
$validated['tooth_code'],
|
||||
$validated,
|
||||
$owner,
|
||||
$this->actorRef($request),
|
||||
$visit,
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'odontogram'])
|
||||
->with('success', 'Tooth '.$validated['tooth_code'].' updated.');
|
||||
}
|
||||
|
||||
public function addPlanItem(
|
||||
Request $request,
|
||||
Visit $visit,
|
||||
SpecialtyModuleService $modules,
|
||||
DentalTreatmentPlanService $plans,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
'procedure_code' => ['required', 'string', 'max:64'],
|
||||
'tooth_code' => ['nullable', 'string', 'max:8'],
|
||||
'surfaces' => ['nullable', 'array'],
|
||||
'surfaces.*' => ['string', 'max:2'],
|
||||
'priority' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
$visit->loadMissing('patient');
|
||||
$plan = $plans->ensurePlan($organization, $visit->patient, $owner, $visit, $this->actorRef($request));
|
||||
|
||||
try {
|
||||
$plans->addItem($plan, $organization, $validated, $owner, $this->actorRef($request));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'plan'])
|
||||
->with('success', 'Added to treatment plan.');
|
||||
}
|
||||
|
||||
public function acceptPlan(
|
||||
Request $request,
|
||||
Visit $visit,
|
||||
SpecialtyModuleService $modules,
|
||||
DentalTreatmentPlanService $plans,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
$visit->loadMissing('patient');
|
||||
$plan = $plans->activePlanForPatient($organization, $visit->patient, $owner);
|
||||
if (! $plan) {
|
||||
return back()->with('error', 'No open treatment plan.');
|
||||
}
|
||||
|
||||
$plans->accept($plan, $owner, $this->actorRef($request));
|
||||
|
||||
return redirect()
|
||||
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'plan'])
|
||||
->with('success', 'Treatment plan accepted.');
|
||||
}
|
||||
|
||||
public function completeProcedure(
|
||||
Request $request,
|
||||
Visit $visit,
|
||||
SpecialtyModuleService $modules,
|
||||
DentalProcedureService $procedures,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
'procedure_code' => ['required', 'string', 'max:64'],
|
||||
'tooth_code' => ['nullable', 'string', 'max:8'],
|
||||
'surfaces' => ['nullable', 'array'],
|
||||
'surfaces.*' => ['string', 'max:2'],
|
||||
'anesthesia' => ['nullable', 'string', 'max:32'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
'plan_item_id' => ['nullable', 'integer'],
|
||||
'bill' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$procedures->complete(
|
||||
$this->organization($request),
|
||||
$visit,
|
||||
[
|
||||
...$validated,
|
||||
'bill' => $request->boolean('bill', true),
|
||||
],
|
||||
$this->ownerRef($request),
|
||||
$this->actorRef($request),
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'treat'])
|
||||
->with('success', 'Procedure recorded and billed.');
|
||||
}
|
||||
|
||||
public function saveNotes(
|
||||
Request $request,
|
||||
Visit $visit,
|
||||
SpecialtyModuleService $modules,
|
||||
DentalVisitNoteService $notes,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
'chief_complaint' => ['nullable', 'string', 'max:5000'],
|
||||
'soft_tissue' => ['nullable', 'string', 'max:5000'],
|
||||
'occlusion' => ['nullable', 'string', 'max:5000'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'],
|
||||
]);
|
||||
|
||||
$notes->upsert(
|
||||
$this->organization($request),
|
||||
$visit,
|
||||
$validated,
|
||||
$this->ownerRef($request),
|
||||
$this->actorRef($request),
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'notes'])
|
||||
->with('success', 'Visit notes saved.');
|
||||
}
|
||||
|
||||
public function uploadImage(
|
||||
Request $request,
|
||||
Visit $visit,
|
||||
SpecialtyModuleService $modules,
|
||||
DentalImagingService $imaging,
|
||||
DentalProcedureService $procedures,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'consultations.manage');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
'attachment' => ['required', 'file', 'max:10240', 'mimes:pdf,jpeg,jpg,png,webp'],
|
||||
'modality' => ['required', 'string', 'in:pa,bw,opg,photo'],
|
||||
'tooth_codes' => ['nullable', 'array'],
|
||||
'tooth_codes.*' => ['string', 'max:8'],
|
||||
'caption' => ['nullable', 'string', 'max:255'],
|
||||
'add_fee' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$imaging->upload(
|
||||
$this->organization($request),
|
||||
$visit,
|
||||
$validated['attachment'],
|
||||
$validated,
|
||||
$this->ownerRef($request),
|
||||
$this->actorRef($request),
|
||||
);
|
||||
|
||||
if ($request->boolean('add_fee')) {
|
||||
$feeCode = match ($validated['modality']) {
|
||||
'pa' => 'den.xray_pa',
|
||||
'bw' => 'den.xray_bw',
|
||||
'opg' => 'den.xray_opg',
|
||||
default => 'den.photo',
|
||||
};
|
||||
$procedures->complete(
|
||||
$this->organization($request),
|
||||
$visit,
|
||||
[
|
||||
'procedure_code' => $feeCode,
|
||||
'tooth_code' => ($validated['tooth_codes'][0] ?? null),
|
||||
'bill' => true,
|
||||
'notes' => 'Imaging fee',
|
||||
],
|
||||
$this->ownerRef($request),
|
||||
$this->actorRef($request),
|
||||
);
|
||||
}
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'imaging'])
|
||||
->with('success', 'Image uploaded.');
|
||||
}
|
||||
|
||||
public function reports(
|
||||
Request $request,
|
||||
SpecialtyModuleService $modules,
|
||||
SpecialtyShellService $shell,
|
||||
DentalAnalyticsService $analytics,
|
||||
): View {
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$organization = $this->organization($request);
|
||||
$member = $this->member($request);
|
||||
$branchScope = app(\App\Services\Care\OrganizationResolver::class)->branchScope($member);
|
||||
|
||||
return view('care.specialty.dentistry.reports', [
|
||||
'moduleKey' => 'dentistry',
|
||||
'definition' => $modules->definition('dentistry'),
|
||||
'report' => $analytics->report($organization, $this->ownerRef($request), $branchScope),
|
||||
'modalities' => DentalCatalog::modalities(),
|
||||
'currency' => config('care.billing.currency', 'GHS'),
|
||||
'shellNav' => $shell->navItems('dentistry'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function printChart(
|
||||
Request $request,
|
||||
Visit $visit,
|
||||
SpecialtyModuleService $modules,
|
||||
DentalChartService $charts,
|
||||
DentalTreatmentPlanService $plans,
|
||||
): StreamedResponse|View {
|
||||
$this->authorizeAbility($request, 'consultations.view');
|
||||
$this->assertDentistryAccess($request, $modules);
|
||||
$this->assertVisit($request, $visit);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
$visit->loadMissing('patient');
|
||||
$chart = $charts->chartForPatient($organization, $visit->patient, $owner);
|
||||
$plan = $plans->activePlanForPatient($organization, $visit->patient, $owner);
|
||||
|
||||
return view('care.specialty.dentistry.print', [
|
||||
'visit' => $visit,
|
||||
'patient' => $visit->patient,
|
||||
'chart' => $chart,
|
||||
'teethMap' => $charts->teethMap($chart),
|
||||
'plan' => $plan,
|
||||
'rows' => DentalCatalog::odontogramRows(),
|
||||
'conditions' => DentalCatalog::conditions(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -283,9 +283,11 @@ class QueueController extends Controller
|
||||
if ($specialtyContext !== CareQueueContexts::CONSULTATION && $appointment->visit) {
|
||||
$clinical = app(\App\Services\Care\SpecialtyClinicalRecordService::class);
|
||||
$shell = app(\App\Services\Care\SpecialtyShellService::class);
|
||||
$preferredTab = collect(array_keys($shell->workspaceTabs($specialtyContext)))
|
||||
->first(fn (string $tab) => $clinical->recordTypeForTab($specialtyContext, $tab) !== null)
|
||||
?? 'clinical_notes';
|
||||
$preferredTab = $specialtyContext === 'dentistry'
|
||||
? 'odontogram'
|
||||
: (collect(array_keys($shell->workspaceTabs($specialtyContext)))
|
||||
->first(fn (string $tab) => $clinical->recordTypeForTab($specialtyContext, $tab) !== null)
|
||||
?? 'clinical_notes');
|
||||
|
||||
return redirect()
|
||||
->route('care.specialty.workspace', [
|
||||
|
||||
@@ -155,9 +155,11 @@ class SpecialtyModuleController extends Controller
|
||||
if ($openConsultation && $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED) {
|
||||
$clinical = app(SpecialtyClinicalRecordService::class);
|
||||
$shellTabs = app(SpecialtyShellService::class)->workspaceTabs($module);
|
||||
$preferredTab = collect(array_keys($shellTabs))
|
||||
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
|
||||
?? 'clinical_notes';
|
||||
$preferredTab = $module === 'dentistry'
|
||||
? 'odontogram'
|
||||
: (collect(array_keys($shellTabs))
|
||||
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
|
||||
?? 'clinical_notes');
|
||||
|
||||
return redirect()
|
||||
->route('care.specialty.workspace', [
|
||||
@@ -196,9 +198,11 @@ class SpecialtyModuleController extends Controller
|
||||
$visit = $visit->fresh() ?? $appointment->visit;
|
||||
$clinical = app(SpecialtyClinicalRecordService::class);
|
||||
$shellTabs = app(SpecialtyShellService::class)->workspaceTabs($module);
|
||||
$preferredTab = collect(array_keys($shellTabs))
|
||||
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
|
||||
?? 'clinical_notes';
|
||||
$preferredTab = $module === 'dentistry'
|
||||
? 'odontogram'
|
||||
: (collect(array_keys($shellTabs))
|
||||
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
|
||||
?? 'clinical_notes');
|
||||
|
||||
return redirect()
|
||||
->route('care.specialty.workspace', [
|
||||
@@ -503,6 +507,18 @@ class SpecialtyModuleController extends Controller
|
||||
$clinicalFields = [];
|
||||
$clinicalAlerts = [];
|
||||
$visitDocuments = collect();
|
||||
$dentalChart = null;
|
||||
$dentalTeethMap = [];
|
||||
$dentalPlan = null;
|
||||
$dentalProcedures = collect();
|
||||
$dentalImages = collect();
|
||||
$dentalVisitNote = null;
|
||||
$dentalCatalog = [];
|
||||
$dentalRows = [];
|
||||
$dentalConditions = [];
|
||||
$dentalStatuses = [];
|
||||
$dentalSurfaces = [];
|
||||
$dentalModalities = [];
|
||||
$activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview');
|
||||
$clinical = app(SpecialtyClinicalRecordService::class);
|
||||
|
||||
@@ -553,6 +569,47 @@ class SpecialtyModuleController extends Controller
|
||||
$clinicalFields = $clinical->fieldsFor($module, $recordType);
|
||||
}
|
||||
$clinicalAlerts = $clinical->alertsForVisit($workspaceVisit, $module);
|
||||
|
||||
if ($module === 'dentistry' && $workspaceVisit->patient) {
|
||||
$chartService = app(\App\Services\Care\Dentistry\DentalChartService::class);
|
||||
$planService = app(\App\Services\Care\Dentistry\DentalTreatmentPlanService::class);
|
||||
$analytics = app(\App\Services\Care\Dentistry\DentalAnalyticsService::class);
|
||||
|
||||
$dentalChart = $chartService->chartForPatient($organization, $workspaceVisit->patient, $owner);
|
||||
$dentalTeethMap = $chartService->teethMap($dentalChart);
|
||||
$dentalPlan = $planService->activePlanForPatient($organization, $workspaceVisit->patient, $owner);
|
||||
$dentalProcedures = \App\Models\DentalProcedure::owned($owner)
|
||||
->where('visit_id', $workspaceVisit->id)
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
$dentalImages = \App\Models\DentalImage::owned($owner)
|
||||
->where(function ($q) use ($workspaceVisit) {
|
||||
$q->where('visit_id', $workspaceVisit->id)
|
||||
->orWhere(function ($q2) use ($workspaceVisit) {
|
||||
$q2->where('patient_id', $workspaceVisit->patient_id)
|
||||
->whereNull('visit_id');
|
||||
});
|
||||
})
|
||||
->with('attachment')
|
||||
->orderByDesc('id')
|
||||
->limit(40)
|
||||
->get();
|
||||
$dentalVisitNote = \App\Models\DentalVisitNote::owned($owner)
|
||||
->where('visit_id', $workspaceVisit->id)
|
||||
->first();
|
||||
$dentalCatalog = $shell->provisionedServices($organization, 'dentistry');
|
||||
$dentalRows = \App\Services\Care\Dentistry\DentalCatalog::odontogramRows();
|
||||
$dentalConditions = \App\Services\Care\Dentistry\DentalCatalog::conditions();
|
||||
$dentalStatuses = \App\Services\Care\Dentistry\DentalCatalog::toothStatuses();
|
||||
$dentalSurfaces = \App\Services\Care\Dentistry\DentalCatalog::surfaces();
|
||||
$dentalModalities = \App\Services\Care\Dentistry\DentalCatalog::modalities();
|
||||
|
||||
$clinicalAlerts = array_merge(
|
||||
$clinicalAlerts,
|
||||
$analytics->alertsForPatient($organization, $workspaceVisit->patient, $owner),
|
||||
);
|
||||
}
|
||||
|
||||
if ($patientHeader !== null) {
|
||||
$patientHeader['clinical_alerts'] = $clinicalAlerts;
|
||||
}
|
||||
@@ -593,6 +650,18 @@ class SpecialtyModuleController extends Controller
|
||||
'clinicalFields' => $clinicalFields,
|
||||
'clinicalAlerts' => $clinicalAlerts,
|
||||
'visitDocuments' => $visitDocuments,
|
||||
'dentalChart' => $dentalChart,
|
||||
'dentalTeethMap' => $dentalTeethMap,
|
||||
'dentalPlan' => $dentalPlan,
|
||||
'dentalProcedures' => $dentalProcedures,
|
||||
'dentalImages' => $dentalImages,
|
||||
'dentalVisitNote' => $dentalVisitNote,
|
||||
'dentalCatalog' => $dentalCatalog,
|
||||
'dentalRows' => $dentalRows,
|
||||
'dentalConditions' => $dentalConditions,
|
||||
'dentalStatuses' => $dentalStatuses,
|
||||
'dentalSurfaces' => $dentalSurfaces,
|
||||
'dentalModalities' => $dentalModalities,
|
||||
'queueStubs' => $modules->queueStubsFor($organization, $module),
|
||||
'branchId' => $branchId,
|
||||
'branchLabel' => $branchLabel,
|
||||
|
||||
Reference in New Issue
Block a user