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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 17:11:15 +00:00
co-authored by Cursor
parent e227f8705f
commit ce782382c5
37 changed files with 2458 additions and 204 deletions
@@ -4,16 +4,24 @@ namespace App\Http\Controllers\Care;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\DentalImage;
use App\Models\DentalLabCase;
use App\Models\DentalPlanItem;
use App\Models\DentalRecall;
use App\Models\Visit;
use App\Services\Care\Dentistry\DentalAnalyticsService;
use App\Services\Care\Dentistry\DentalCatalog;
use App\Services\Care\Dentistry\DentalChartService;
use App\Services\Care\Dentistry\DentalImagingService;
use App\Services\Care\Dentistry\DentalLabCaseService;
use App\Services\Care\Dentistry\DentalPerioService;
use App\Services\Care\Dentistry\DentalProcedureService;
use App\Services\Care\Dentistry\DentalRecallService;
use App\Services\Care\Dentistry\DentalTreatmentPlanService;
use App\Services\Care\Dentistry\DentalVisitNoteService;
use App\Services\Care\SpecialtyModuleService;
use App\Services\Care\SpecialtyShellService;
use App\Services\Care\SpecialtyVisitStageService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -279,10 +287,13 @@ class DentistryWorkspaceController extends Controller
$member = $this->member($request);
$branchScope = app(\App\Services\Care\OrganizationResolver::class)->branchScope($member);
$from = $request->query('from');
$to = $request->query('to');
return view('care.specialty.dentistry.reports', [
'moduleKey' => 'dentistry',
'definition' => $modules->definition('dentistry'),
'report' => $analytics->report($organization, $this->ownerRef($request), $branchScope),
'report' => $analytics->report($organization, $this->ownerRef($request), $branchScope, $from, $to),
'modalities' => DentalCatalog::modalities(),
'currency' => config('care.billing.currency', 'GHS'),
'shellNav' => $shell->navItems('dentistry'),
@@ -316,4 +327,460 @@ class DentistryWorkspaceController extends Controller
'conditions' => DentalCatalog::conditions(),
]);
}
public function setDentition(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalChartService $charts,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'dentition' => ['required', 'string', 'in:adult,primary,mixed'],
]);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$chart = $charts->chartForPatient($organization, $visit->patient, $owner);
try {
$charts->setDentition(
$chart,
$validated['dentition'],
$owner,
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'odontogram'])
->with('success', 'Dentition updated.');
}
public function updatePlanItem(
Request $request,
Visit $visit,
DentalPlanItem $item,
SpecialtyModuleService $modules,
DentalTreatmentPlanService $plans,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'procedure_code' => ['required', 'string', 'max:64'],
'tooth_code' => ['nullable', 'string', 'max:8'],
'surfaces' => ['nullable', 'array'],
'surfaces.*' => ['string', 'max:2'],
'priority' => ['nullable', 'integer', 'min:1', 'max:100'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$plan = $plans->activePlanForPatient($organization, $visit->patient, $owner);
abort_unless($plan && $item->treatment_plan_id === $plan->id, 404);
try {
$plans->updateItem($item, $organization, $validated, $owner, $this->actorRef($request));
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'plan'])
->with('success', 'Plan item updated.');
}
public function cancelPlanItem(
Request $request,
Visit $visit,
DentalPlanItem $item,
SpecialtyModuleService $modules,
DentalTreatmentPlanService $plans,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$plan = $plans->activePlanForPatient($organization, $visit->patient, $owner);
abort_unless($plan && $item->treatment_plan_id === $plan->id, 404);
try {
$plans->cancelItem($item, $owner, $this->actorRef($request));
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'plan'])
->with('success', 'Plan item cancelled.');
}
public function rejectPlan(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalTreatmentPlanService $plans,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'rejection_reason' => ['nullable', 'string', 'max:2000'],
]);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$plan = $plans->activePlanForPatient($organization, $visit->patient, $owner);
if (! $plan) {
return back()->with('error', 'No open treatment plan.');
}
try {
$plans->rejectPlan(
$plan,
$owner,
$validated['rejection_reason'] ?? null,
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'plan'])
->with('success', 'Treatment plan rejected.');
}
public function cancelPlan(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalTreatmentPlanService $plans,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$plan = $plans->activePlanForPatient($organization, $visit->patient, $owner);
if (! $plan) {
return back()->with('error', 'No open treatment plan.');
}
try {
$plans->cancelPlan($plan, $owner, $this->actorRef($request));
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'plan'])
->with('success', 'Treatment plan cancelled.');
}
public function voidImage(
Request $request,
Visit $visit,
DentalImage $image,
SpecialtyModuleService $modules,
DentalImagingService $imaging,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$organization = $this->organization($request);
$visit->loadMissing('patient');
abort_unless(
$image->organization_id === $organization->id
&& ($image->visit_id === $visit->id || $image->patient_id === $visit->patient_id),
404,
);
try {
$imaging->void($image, $this->ownerRef($request), $this->actorRef($request));
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'imaging'])
->with('success', 'Image removed.');
}
public function setStage(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
SpecialtyVisitStageService $stages,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'stage' => ['required', 'string', 'max:32'],
]);
try {
$stages->setStage(
$this->organization($request),
$visit,
'dentistry',
$validated['stage'],
$this->ownerRef($request),
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'overview'])
->with('success', 'Visit stage updated.');
}
public function savePerio(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalPerioService $perio,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'sites' => ['nullable', 'array'],
'sites.*.tooth_code' => ['required', 'string', 'max:8'],
'sites.*.surface' => ['required', 'string', 'max:8'],
'sites.*.probing_mm' => ['nullable', 'integer', 'min:0', 'max:20'],
'sites.*.bleeding' => ['nullable', 'boolean'],
'sites.*.plaque' => ['nullable', 'boolean'],
'sites.*.recession_mm' => ['nullable', 'integer', 'min:0', 'max:20'],
'notes' => ['nullable', 'string', 'max:5000'],
]);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
try {
$perio->saveExam(
$organization,
$visit->patient,
$owner,
$validated['sites'] ?? [],
$visit,
$validated['notes'] ?? null,
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'perio'])
->with('success', 'Perio exam saved.');
}
public function createLabCase(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalLabCaseService $labCases,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'case_type' => ['required', 'string', 'in:crown,bridge,denture,veneer,ortho,other'],
'lab_name' => ['nullable', 'string', 'max:255'],
'tooth_codes' => ['nullable', 'array'],
'tooth_codes.*' => ['string', 'max:8'],
'due_at' => ['nullable', 'date'],
'notes' => ['nullable', 'string', 'max:2000'],
'status' => ['nullable', 'string', 'in:draft,ordered,sent,received,fitted,cancelled'],
]);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
try {
$labCases->create(
$organization,
$visit->patient,
$owner,
$validated,
$visit,
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'lab'])
->with('success', 'Lab case created.');
}
public function transitionLabCase(
Request $request,
Visit $visit,
DentalLabCase $labCase,
SpecialtyModuleService $modules,
DentalLabCaseService $labCases,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'status' => ['required', 'string', 'in:draft,ordered,sent,received,fitted,cancelled'],
]);
$organization = $this->organization($request);
$visit->loadMissing('patient');
abort_unless(
$labCase->organization_id === $organization->id
&& $labCase->patient_id === $visit->patient_id,
404,
);
try {
$labCases->transition(
$labCase,
$validated['status'],
$this->ownerRef($request),
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'lab'])
->with('success', 'Lab case updated.');
}
public function scheduleRecall(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalRecallService $recalls,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'due_on' => ['required', 'date'],
'reason' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
try {
$recalls->schedule(
$organization,
$visit->patient,
$owner,
$validated,
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'recalls'])
->with('success', 'Recall scheduled.');
}
public function completeRecall(
Request $request,
Visit $visit,
DentalRecall $recall,
SpecialtyModuleService $modules,
DentalRecallService $recalls,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$organization = $this->organization($request);
$visit->loadMissing('patient');
abort_unless(
$recall->organization_id === $organization->id
&& $recall->patient_id === $visit->patient_id,
404,
);
try {
$recalls->complete(
$recall,
$this->ownerRef($request),
$visit,
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'recalls'])
->with('success', 'Recall completed.');
}
public function cancelRecall(
Request $request,
Visit $visit,
DentalRecall $recall,
SpecialtyModuleService $modules,
DentalRecallService $recalls,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$organization = $this->organization($request);
$visit->loadMissing('patient');
abort_unless(
$recall->organization_id === $organization->id
&& $recall->patient_id === $visit->patient_id,
404,
);
try {
$recalls->cancel($recall, $this->ownerRef($request), $this->actorRef($request));
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'recalls'])
->with('success', 'Recall cancelled.');
}
}
@@ -197,6 +197,31 @@ class SpecialtyModuleController extends Controller
}
$visit = $visit->fresh() ?? $appointment->visit;
if ($visit) {
$stageService = app(\App\Services\Care\SpecialtyVisitStageService::class);
$nextStage = $stageService->stageOnConsultationStart($module);
if ($nextStage && ! $visit->specialty_stage) {
try {
$stageService->setStage(
$organization,
$visit,
$module,
$nextStage,
$owner,
$owner,
);
$visit = $visit->fresh();
} catch (\InvalidArgumentException) {
// Stage map may be empty for some modules.
}
} elseif ($nextStage && $visit->specialty_stage === 'waiting') {
try {
$stageService->setStage($organization, $visit, $module, $nextStage, $owner, $owner);
$visit = $visit->fresh();
} catch (\InvalidArgumentException) {
}
}
}
$clinical = app(SpecialtyClinicalRecordService::class);
$shellTabs = app(SpecialtyShellService::class)->workspaceTabs($module);
$preferredTab = $module === 'dentistry'
@@ -512,6 +537,7 @@ class SpecialtyModuleController extends Controller
$dentalChart = null;
$dentalTeethMap = [];
$dentalPlan = null;
$dentalPlanHistory = collect();
$dentalProcedures = collect();
$dentalImages = collect();
$dentalVisitNote = null;
@@ -521,6 +547,14 @@ class SpecialtyModuleController extends Controller
$dentalStatuses = [];
$dentalSurfaces = [];
$dentalModalities = [];
$dentalDentitions = [];
$dentalPerioExam = null;
$dentalPerioSurfaces = [];
$dentalLabCases = collect();
$dentalLabCaseTypes = [];
$dentalLabCaseStatuses = [];
$dentalRecalls = collect();
$dentalStageCodes = [];
$activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview');
$clinical = app(SpecialtyClinicalRecordService::class);
@@ -576,10 +610,14 @@ class SpecialtyModuleController extends Controller
$chartService = app(\App\Services\Care\Dentistry\DentalChartService::class);
$planService = app(\App\Services\Care\Dentistry\DentalTreatmentPlanService::class);
$analytics = app(\App\Services\Care\Dentistry\DentalAnalyticsService::class);
$perioService = app(\App\Services\Care\Dentistry\DentalPerioService::class);
$labService = app(\App\Services\Care\Dentistry\DentalLabCaseService::class);
$recallService = app(\App\Services\Care\Dentistry\DentalRecallService::class);
$dentalChart = $chartService->chartForPatient($organization, $workspaceVisit->patient, $owner);
$dentalTeethMap = $chartService->teethMap($dentalChart);
$dentalPlan = $planService->activePlanForPatient($organization, $workspaceVisit->patient, $owner);
$dentalPlanHistory = $planService->plansForPatient($organization, $workspaceVisit->patient, $owner);
$dentalProcedures = \App\Models\DentalProcedure::owned($owner)
->where('visit_id', $workspaceVisit->id)
->orderByDesc('id')
@@ -600,11 +638,20 @@ class SpecialtyModuleController extends Controller
->where('visit_id', $workspaceVisit->id)
->first();
$dentalCatalog = $shell->provisionedServices($organization, 'dentistry');
$dentalRows = \App\Services\Care\Dentistry\DentalCatalog::odontogramRows();
$dentition = $dentalChart->dentition ?: \App\Services\Care\Dentistry\DentalCatalog::DENTITION_ADULT;
$dentalRows = \App\Services\Care\Dentistry\DentalCatalog::odontogramRows($dentition);
$dentalConditions = \App\Services\Care\Dentistry\DentalCatalog::conditions();
$dentalStatuses = \App\Services\Care\Dentistry\DentalCatalog::toothStatuses();
$dentalSurfaces = \App\Services\Care\Dentistry\DentalCatalog::surfaces();
$dentalModalities = \App\Services\Care\Dentistry\DentalCatalog::modalities();
$dentalDentitions = \App\Services\Care\Dentistry\DentalCatalog::dentitions();
$dentalPerioExam = $perioService->latestForPatient($organization, $workspaceVisit->patient, $owner);
$dentalPerioSurfaces = \App\Services\Care\Dentistry\DentalCatalog::perioSurfaces();
$dentalLabCases = $labService->casesForPatient($organization, $workspaceVisit->patient, $owner);
$dentalLabCaseTypes = \App\Services\Care\Dentistry\DentalCatalog::labCaseTypes();
$dentalLabCaseStatuses = \App\Services\Care\Dentistry\DentalCatalog::labCaseStatuses();
$dentalRecalls = $recallService->recallsForPatient($organization, $workspaceVisit->patient, $owner);
$dentalStageCodes = collect($shell->stages('dentistry'))->pluck('code')->all();
$clinicalAlerts = array_merge(
$clinicalAlerts,
@@ -664,6 +711,7 @@ class SpecialtyModuleController extends Controller
'dentalChart' => $dentalChart,
'dentalTeethMap' => $dentalTeethMap,
'dentalPlan' => $dentalPlan,
'dentalPlanHistory' => $dentalPlanHistory,
'dentalProcedures' => $dentalProcedures,
'dentalImages' => $dentalImages,
'dentalVisitNote' => $dentalVisitNote,
@@ -673,6 +721,14 @@ class SpecialtyModuleController extends Controller
'dentalStatuses' => $dentalStatuses,
'dentalSurfaces' => $dentalSurfaces,
'dentalModalities' => $dentalModalities,
'dentalDentitions' => $dentalDentitions,
'dentalPerioExam' => $dentalPerioExam,
'dentalPerioSurfaces' => $dentalPerioSurfaces,
'dentalLabCases' => $dentalLabCases,
'dentalLabCaseTypes' => $dentalLabCaseTypes,
'dentalLabCaseStatuses' => $dentalLabCaseStatuses,
'dentalRecalls' => $dentalRecalls,
'dentalStageCodes' => $dentalStageCodes,
'queueStubs' => $modules->queueStubsFor($organization, $module),
'branchId' => $branchId,
'branchLabel' => $branchLabel,
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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';
+72
View File
@@ -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');
}
}
+57
View File
@@ -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');
}
}
+29
View File
@@ -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');
}
}
+59
View File
@@ -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');
}
}
+7 -1
View File
@@ -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',
];
}
+1 -1
View File
@@ -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
+56 -7
View File
@@ -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
+33
View File
@@ -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;
}
}
+113 -7
View File
@@ -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.');
}
}
}
+28 -7
View File
@@ -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,
]),
]);
}
}
+3
View File
@@ -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">
+13
View File
@@ -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');
+127
View File
@@ -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');
}
}
+1 -63
View File
@@ -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