Ship commercial Dentistry suite with odontogram and treatment plans.
Deploy Ladill Care / deploy (push) Failing after 36s

Replace placeholder clinical forms with patient-level FDI charting, multi-visit plans, procedure-to-bill linking, imaging, reports, and demo seed data.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 16:33:34 +00:00
co-authored by Cursor
parent a00a8249ad
commit 0edd653187
37 changed files with 2906 additions and 35 deletions
@@ -0,0 +1,319 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\Visit;
use App\Services\Care\Dentistry\DentalAnalyticsService;
use App\Services\Care\Dentistry\DentalCatalog;
use App\Services\Care\Dentistry\DentalChartService;
use App\Services\Care\Dentistry\DentalImagingService;
use App\Services\Care\Dentistry\DentalProcedureService;
use App\Services\Care\Dentistry\DentalTreatmentPlanService;
use App\Services\Care\Dentistry\DentalVisitNoteService;
use App\Services\Care\SpecialtyModuleService;
use App\Services\Care\SpecialtyShellService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DentistryWorkspaceController extends Controller
{
use ScopesToAccount;
protected function assertDentistryAccess(Request $request, SpecialtyModuleService $modules): void
{
$organization = $this->organization($request);
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'dentistry'), 403);
}
protected function assertVisit(Request $request, Visit $visit): void
{
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
$this->authorizeBranch($request, (int) $visit->branch_id);
}
public function updateTooth(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalChartService $charts,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'tooth_code' => ['required', 'string', 'max:8'],
'status' => ['required', 'string', 'in:present,missing,extracted,implant'],
'surfaces' => ['nullable', 'array'],
'surfaces.*' => ['string', 'max:2'],
'conditions' => ['nullable', 'array'],
'conditions.*' => ['string', 'max:32'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$chart = $charts->chartForPatient($organization, $visit->patient, $owner);
try {
$charts->updateTooth(
$chart,
$validated['tooth_code'],
$validated,
$owner,
$this->actorRef($request),
$visit,
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'odontogram'])
->with('success', 'Tooth '.$validated['tooth_code'].' updated.');
}
public function addPlanItem(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalTreatmentPlanService $plans,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'procedure_code' => ['required', 'string', 'max:64'],
'tooth_code' => ['nullable', 'string', 'max:8'],
'surfaces' => ['nullable', 'array'],
'surfaces.*' => ['string', 'max:2'],
'priority' => ['nullable', 'integer', 'min:1', 'max:100'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$plan = $plans->ensurePlan($organization, $visit->patient, $owner, $visit, $this->actorRef($request));
try {
$plans->addItem($plan, $organization, $validated, $owner, $this->actorRef($request));
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'plan'])
->with('success', 'Added to treatment plan.');
}
public function acceptPlan(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalTreatmentPlanService $plans,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$plan = $plans->activePlanForPatient($organization, $visit->patient, $owner);
if (! $plan) {
return back()->with('error', 'No open treatment plan.');
}
$plans->accept($plan, $owner, $this->actorRef($request));
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'plan'])
->with('success', 'Treatment plan accepted.');
}
public function completeProcedure(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalProcedureService $procedures,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'procedure_code' => ['required', 'string', 'max:64'],
'tooth_code' => ['nullable', 'string', 'max:8'],
'surfaces' => ['nullable', 'array'],
'surfaces.*' => ['string', 'max:2'],
'anesthesia' => ['nullable', 'string', 'max:32'],
'notes' => ['nullable', 'string', 'max:2000'],
'plan_item_id' => ['nullable', 'integer'],
'bill' => ['nullable', 'boolean'],
]);
try {
$procedures->complete(
$this->organization($request),
$visit,
[
...$validated,
'bill' => $request->boolean('bill', true),
],
$this->ownerRef($request),
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'treat'])
->with('success', 'Procedure recorded and billed.');
}
public function saveNotes(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalVisitNoteService $notes,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'chief_complaint' => ['nullable', 'string', 'max:5000'],
'soft_tissue' => ['nullable', 'string', 'max:5000'],
'occlusion' => ['nullable', 'string', 'max:5000'],
'notes' => ['nullable', 'string', 'max:5000'],
]);
$notes->upsert(
$this->organization($request),
$visit,
$validated,
$this->ownerRef($request),
$this->actorRef($request),
);
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'notes'])
->with('success', 'Visit notes saved.');
}
public function uploadImage(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalImagingService $imaging,
DentalProcedureService $procedures,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'attachment' => ['required', 'file', 'max:10240', 'mimes:pdf,jpeg,jpg,png,webp'],
'modality' => ['required', 'string', 'in:pa,bw,opg,photo'],
'tooth_codes' => ['nullable', 'array'],
'tooth_codes.*' => ['string', 'max:8'],
'caption' => ['nullable', 'string', 'max:255'],
'add_fee' => ['nullable', 'boolean'],
]);
try {
$imaging->upload(
$this->organization($request),
$visit,
$validated['attachment'],
$validated,
$this->ownerRef($request),
$this->actorRef($request),
);
if ($request->boolean('add_fee')) {
$feeCode = match ($validated['modality']) {
'pa' => 'den.xray_pa',
'bw' => 'den.xray_bw',
'opg' => 'den.xray_opg',
default => 'den.photo',
};
$procedures->complete(
$this->organization($request),
$visit,
[
'procedure_code' => $feeCode,
'tooth_code' => ($validated['tooth_codes'][0] ?? null),
'bill' => true,
'notes' => 'Imaging fee',
],
$this->ownerRef($request),
$this->actorRef($request),
);
}
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'imaging'])
->with('success', 'Image uploaded.');
}
public function reports(
Request $request,
SpecialtyModuleService $modules,
SpecialtyShellService $shell,
DentalAnalyticsService $analytics,
): View {
$this->assertDentistryAccess($request, $modules);
$organization = $this->organization($request);
$member = $this->member($request);
$branchScope = app(\App\Services\Care\OrganizationResolver::class)->branchScope($member);
return view('care.specialty.dentistry.reports', [
'moduleKey' => 'dentistry',
'definition' => $modules->definition('dentistry'),
'report' => $analytics->report($organization, $this->ownerRef($request), $branchScope),
'modalities' => DentalCatalog::modalities(),
'currency' => config('care.billing.currency', 'GHS'),
'shellNav' => $shell->navItems('dentistry'),
]);
}
public function printChart(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalChartService $charts,
DentalTreatmentPlanService $plans,
): StreamedResponse|View {
$this->authorizeAbility($request, 'consultations.view');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$chart = $charts->chartForPatient($organization, $visit->patient, $owner);
$plan = $plans->activePlanForPatient($organization, $visit->patient, $owner);
return view('care.specialty.dentistry.print', [
'visit' => $visit,
'patient' => $visit->patient,
'chart' => $chart,
'teethMap' => $charts->teethMap($chart),
'plan' => $plan,
'rows' => DentalCatalog::odontogramRows(),
'conditions' => DentalCatalog::conditions(),
]);
}
}
@@ -283,9 +283,11 @@ class QueueController extends Controller
if ($specialtyContext !== CareQueueContexts::CONSULTATION && $appointment->visit) {
$clinical = app(\App\Services\Care\SpecialtyClinicalRecordService::class);
$shell = app(\App\Services\Care\SpecialtyShellService::class);
$preferredTab = collect(array_keys($shell->workspaceTabs($specialtyContext)))
$preferredTab = $specialtyContext === 'dentistry'
? 'odontogram'
: (collect(array_keys($shell->workspaceTabs($specialtyContext)))
->first(fn (string $tab) => $clinical->recordTypeForTab($specialtyContext, $tab) !== null)
?? 'clinical_notes';
?? 'clinical_notes');
return redirect()
->route('care.specialty.workspace', [
@@ -155,9 +155,11 @@ class SpecialtyModuleController extends Controller
if ($openConsultation && $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED) {
$clinical = app(SpecialtyClinicalRecordService::class);
$shellTabs = app(SpecialtyShellService::class)->workspaceTabs($module);
$preferredTab = collect(array_keys($shellTabs))
$preferredTab = $module === 'dentistry'
? 'odontogram'
: (collect(array_keys($shellTabs))
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
?? 'clinical_notes';
?? 'clinical_notes');
return redirect()
->route('care.specialty.workspace', [
@@ -196,9 +198,11 @@ class SpecialtyModuleController extends Controller
$visit = $visit->fresh() ?? $appointment->visit;
$clinical = app(SpecialtyClinicalRecordService::class);
$shellTabs = app(SpecialtyShellService::class)->workspaceTabs($module);
$preferredTab = collect(array_keys($shellTabs))
$preferredTab = $module === 'dentistry'
? 'odontogram'
: (collect(array_keys($shellTabs))
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
?? 'clinical_notes';
?? 'clinical_notes');
return redirect()
->route('care.specialty.workspace', [
@@ -503,6 +507,18 @@ class SpecialtyModuleController extends Controller
$clinicalFields = [];
$clinicalAlerts = [];
$visitDocuments = collect();
$dentalChart = null;
$dentalTeethMap = [];
$dentalPlan = null;
$dentalProcedures = collect();
$dentalImages = collect();
$dentalVisitNote = null;
$dentalCatalog = [];
$dentalRows = [];
$dentalConditions = [];
$dentalStatuses = [];
$dentalSurfaces = [];
$dentalModalities = [];
$activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview');
$clinical = app(SpecialtyClinicalRecordService::class);
@@ -553,6 +569,47 @@ class SpecialtyModuleController extends Controller
$clinicalFields = $clinical->fieldsFor($module, $recordType);
}
$clinicalAlerts = $clinical->alertsForVisit($workspaceVisit, $module);
if ($module === 'dentistry' && $workspaceVisit->patient) {
$chartService = app(\App\Services\Care\Dentistry\DentalChartService::class);
$planService = app(\App\Services\Care\Dentistry\DentalTreatmentPlanService::class);
$analytics = app(\App\Services\Care\Dentistry\DentalAnalyticsService::class);
$dentalChart = $chartService->chartForPatient($organization, $workspaceVisit->patient, $owner);
$dentalTeethMap = $chartService->teethMap($dentalChart);
$dentalPlan = $planService->activePlanForPatient($organization, $workspaceVisit->patient, $owner);
$dentalProcedures = \App\Models\DentalProcedure::owned($owner)
->where('visit_id', $workspaceVisit->id)
->orderByDesc('id')
->get();
$dentalImages = \App\Models\DentalImage::owned($owner)
->where(function ($q) use ($workspaceVisit) {
$q->where('visit_id', $workspaceVisit->id)
->orWhere(function ($q2) use ($workspaceVisit) {
$q2->where('patient_id', $workspaceVisit->patient_id)
->whereNull('visit_id');
});
})
->with('attachment')
->orderByDesc('id')
->limit(40)
->get();
$dentalVisitNote = \App\Models\DentalVisitNote::owned($owner)
->where('visit_id', $workspaceVisit->id)
->first();
$dentalCatalog = $shell->provisionedServices($organization, 'dentistry');
$dentalRows = \App\Services\Care\Dentistry\DentalCatalog::odontogramRows();
$dentalConditions = \App\Services\Care\Dentistry\DentalCatalog::conditions();
$dentalStatuses = \App\Services\Care\Dentistry\DentalCatalog::toothStatuses();
$dentalSurfaces = \App\Services\Care\Dentistry\DentalCatalog::surfaces();
$dentalModalities = \App\Services\Care\Dentistry\DentalCatalog::modalities();
$clinicalAlerts = array_merge(
$clinicalAlerts,
$analytics->alertsForPatient($organization, $workspaceVisit->patient, $owner),
);
}
if ($patientHeader !== null) {
$patientHeader['clinical_alerts'] = $clinicalAlerts;
}
@@ -593,6 +650,18 @@ class SpecialtyModuleController extends Controller
'clinicalFields' => $clinicalFields,
'clinicalAlerts' => $clinicalAlerts,
'visitDocuments' => $visitDocuments,
'dentalChart' => $dentalChart,
'dentalTeethMap' => $dentalTeethMap,
'dentalPlan' => $dentalPlan,
'dentalProcedures' => $dentalProcedures,
'dentalImages' => $dentalImages,
'dentalVisitNote' => $dentalVisitNote,
'dentalCatalog' => $dentalCatalog,
'dentalRows' => $dentalRows,
'dentalConditions' => $dentalConditions,
'dentalStatuses' => $dentalStatuses,
'dentalSurfaces' => $dentalSurfaces,
'dentalModalities' => $dentalModalities,
'queueStubs' => $modules->queueStubsFor($organization, $module),
'branchId' => $branchId,
'branchLabel' => $branchLabel,
+60
View File
@@ -0,0 +1,60 @@
<?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 DentalChart extends Model
{
use BelongsToOwner;
protected $table = 'care_dental_charts';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'patient_id',
'notation', 'charted_at', 'charted_by',
];
protected function casts(): array
{
return ['charted_at' => 'datetime'];
}
protected static function booted(): void
{
static::creating(function (DentalChart $chart) {
if (! $chart->uuid) {
$chart->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function teeth(): HasMany
{
return $this->hasMany(DentalTooth::class, 'dental_chart_id');
}
public function events(): HasMany
{
return $this->hasMany(DentalChartEvent::class, 'dental_chart_id');
}
}
+52
View File
@@ -0,0 +1,52 @@
<?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 DentalChartEvent extends Model
{
use BelongsToOwner;
protected $table = 'care_dental_chart_events';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'dental_chart_id', 'patient_id',
'visit_id', 'tooth_code', 'event_type', 'payload', 'recorded_by', 'recorded_at',
];
protected function casts(): array
{
return [
'payload' => 'array',
'recorded_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (DentalChartEvent $event) {
if (! $event->uuid) {
$event->uuid = (string) Str::uuid();
}
});
}
public function chart(): BelongsTo
{
return $this->belongsTo(DentalChart::class, 'dental_chart_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
}
+66
View File
@@ -0,0 +1,66 @@
<?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 DentalImage extends Model
{
use BelongsToOwner;
public const MODALITY_PA = 'pa';
public const MODALITY_BW = 'bw';
public const MODALITY_OPG = 'opg';
public const MODALITY_PHOTO = 'photo';
protected $table = 'care_dental_images';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'patient_id', 'visit_id',
'patient_attachment_id', 'modality', 'tooth_codes', 'caption',
'uploaded_by', 'captured_at',
];
protected function casts(): array
{
return [
'tooth_codes' => 'array',
'captured_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (DentalImage $image) {
if (! $image->uuid) {
$image->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function attachment(): BelongsTo
{
return $this->belongsTo(PatientAttachment::class, 'patient_attachment_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class DentalPlanItem extends Model
{
public const STATUS_PROPOSED = 'proposed';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_DONE = 'done';
public const STATUS_CANCELLED = 'cancelled';
protected $table = 'care_dental_plan_items';
protected $fillable = [
'uuid', 'treatment_plan_id', 'tooth_code', 'surfaces', 'procedure_code',
'label', 'priority', 'status', 'estimate_minor', 'bill_line_item_id', 'notes',
];
protected function casts(): array
{
return ['surfaces' => 'array'];
}
protected static function booted(): void
{
static::creating(function (DentalPlanItem $item) {
if (! $item->uuid) {
$item->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function plan(): BelongsTo
{
return $this->belongsTo(DentalTreatmentPlan::class, 'treatment_plan_id');
}
public function billLineItem(): BelongsTo
{
return $this->belongsTo(BillLineItem::class, 'bill_line_item_id');
}
}
+67
View File
@@ -0,0 +1,67 @@
<?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 DentalProcedure extends Model
{
use BelongsToOwner;
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
protected $table = 'care_dental_procedures';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'patient_id', 'visit_id', 'plan_item_id',
'tooth_code', 'surfaces', 'procedure_code', 'label', 'anesthesia', 'status',
'amount_minor', 'bill_line_item_id', 'provider_ref', 'notes', 'performed_at',
];
protected function casts(): array
{
return [
'surfaces' => 'array',
'performed_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (DentalProcedure $procedure) {
if (! $procedure->uuid) {
$procedure->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function planItem(): BelongsTo
{
return $this->belongsTo(DentalPlanItem::class, 'plan_item_id');
}
public function billLineItem(): BelongsTo
{
return $this->belongsTo(BillLineItem::class, 'bill_line_item_id');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DentalTooth extends Model
{
public const STATUS_PRESENT = 'present';
public const STATUS_MISSING = 'missing';
public const STATUS_EXTRACTED = 'extracted';
public const STATUS_IMPLANT = 'implant';
protected $table = 'care_dental_teeth';
protected $fillable = [
'dental_chart_id', 'tooth_code', 'status', 'surfaces', 'conditions', 'notes',
];
protected function casts(): array
{
return [
'surfaces' => 'array',
'conditions' => 'array',
];
}
public function chart(): BelongsTo
{
return $this->belongsTo(DentalChart::class, 'dental_chart_id');
}
}
+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\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class DentalTreatmentPlan extends Model
{
use BelongsToOwner, SoftDeletes;
public const STATUS_DRAFT = 'draft';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_COMPLETED = 'completed';
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',
];
protected function casts(): array
{
return [
'accepted_at' => 'datetime',
'completed_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (DentalTreatmentPlan $plan) {
if (! $plan->uuid) {
$plan->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function items(): HasMany
{
return $this->hasMany(DentalPlanItem::class, 'treatment_plan_id')->orderBy('priority')->orderBy('id');
}
}
+45
View File
@@ -0,0 +1,45 @@
<?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 DentalVisitNote extends Model
{
use BelongsToOwner;
protected $table = 'care_dental_visit_notes';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'visit_id', 'patient_id',
'chief_complaint', 'soft_tissue', 'occlusion', 'notes',
'recorded_by', 'recorded_at',
];
protected function casts(): array
{
return ['recorded_at' => 'datetime'];
}
protected static function booted(): void
{
static::creating(function (DentalVisitNote $note) {
if (! $note->uuid) {
$note->uuid = (string) Str::uuid();
}
});
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
}
+76
View File
@@ -949,9 +949,85 @@ class DemoTenantSeeder
}
}
$this->seedDentistryClinicalDemo($organization, $ownerRef);
return $count;
}
private function seedDentistryClinicalDemo(Organization $organization, string $ownerRef): void
{
$departmentIds = Department::owned($ownerRef)
->where('organization_id', $organization->id)
->where('type', 'dental')
->where('is_active', true)
->pluck('id');
if ($departmentIds->isEmpty()) {
return;
}
$appointment = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('department_id', $departmentIds)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN, Appointment::STATUS_IN_CONSULTATION])
->whereNotNull('visit_id')
->with(['patient', 'visit'])
->latest('id')
->first();
if (! $appointment?->patient || ! $appointment->visit) {
return;
}
$charts = app(\App\Services\Care\Dentistry\DentalChartService::class);
$plans = app(\App\Services\Care\Dentistry\DentalTreatmentPlanService::class);
$procedures = app(\App\Services\Care\Dentistry\DentalProcedureService::class);
$chart = $charts->chartForPatient($organization, $appointment->patient, $ownerRef);
$charts->updateTooth($chart, '16', [
'status' => 'present',
'surfaces' => ['O'],
'conditions' => ['caries'],
'notes' => 'Demo occlusal caries',
], $ownerRef, $ownerRef, $appointment->visit);
$plan = $plans->ensurePlan($organization, $appointment->patient, $ownerRef, $appointment->visit, $ownerRef);
if ($plan->items()->count() === 0) {
$plans->addItem($plan, $organization, [
'procedure_code' => 'den.fill',
'tooth_code' => '16',
'surfaces' => ['O'],
'priority' => 10,
'notes' => 'Demo filling',
], $ownerRef, $ownerRef);
$plans->accept($plan->fresh('items'), $ownerRef, $ownerRef);
}
$completedPatient = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('department_id', $departmentIds)
->where('status', Appointment::STATUS_COMPLETED)
->whereNotNull('visit_id')
->with('visit')
->latest('id')
->first();
if ($completedPatient?->visit && ! \App\Models\DentalProcedure::owned($ownerRef)->where('visit_id', $completedPatient->visit_id)->exists()) {
try {
$procedures->complete($organization, $completedPatient->visit, [
'procedure_code' => 'den.fill',
'tooth_code' => '26',
'surfaces' => ['O'],
'anesthesia' => 'Local',
'bill' => true,
'notes' => 'Demo completed fill',
], $ownerRef, $ownerRef);
} catch (\Throwable) {
// Billing may be constrained in partial seeds — chart/plan still valuable.
}
}
}
/**
* @param list<Branch> $branches
* @param array<int, list<Department>> $departments
@@ -0,0 +1,155 @@
<?php
namespace App\Services\Care\Dentistry;
use App\Models\Appointment;
use App\Models\BillLineItem;
use App\Models\DentalImage;
use App\Models\DentalPlanItem;
use App\Models\DentalProcedure;
use App\Models\DentalTreatmentPlan;
use App\Models\Organization;
use App\Models\Patient;
use Illuminate\Support\Collection;
class DentalAnalyticsService
{
/**
* @return array{
* procedures_today: Collection,
* procedures_month: Collection,
* revenue_by_code: Collection,
* unfinished_items: int,
* avg_chair_minutes: ?float,
* imaging_by_modality: Collection
* }
*/
public function report(
Organization $organization,
string $ownerRef,
?int $branchId = null,
): array {
$todayStart = now()->startOfDay();
$monthStart = now()->startOfMonth();
$procBase = DentalProcedure::owned($ownerRef)
->where('organization_id', $organization->id)
->where('status', DentalProcedure::STATUS_COMPLETED);
$proceduresToday = (clone $procBase)
->where('performed_at', '>=', $todayStart)
->selectRaw('procedure_code, label, count(*) as total, sum(amount_minor) as amount_minor')
->groupBy('procedure_code', 'label')
->orderByDesc('total')
->get();
$proceduresMonth = (clone $procBase)
->where('performed_at', '>=', $monthStart)
->selectRaw('procedure_code, label, count(*) as total, sum(amount_minor) as amount_minor')
->groupBy('procedure_code', 'label')
->orderByDesc('total')
->get();
$revenueByCode = BillLineItem::query()
->where('owner_ref', $ownerRef)
->where('source_type', 'dental_procedure')
->where('created_at', '>=', $monthStart)
->selectRaw('description, sum(total_minor) as amount_minor, count(*) as total')
->groupBy('description')
->orderByDesc('amount_minor')
->limit(20)
->get();
$unfinished = DentalPlanItem::query()
->whereHas('plan', function ($q) use ($organization, $ownerRef) {
$q->owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('status', [
DentalTreatmentPlan::STATUS_ACCEPTED,
DentalTreatmentPlan::STATUS_IN_PROGRESS,
]);
})
->whereIn('status', [DentalPlanItem::STATUS_ACCEPTED, DentalPlanItem::STATUS_PROPOSED])
->count();
$chairQuery = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->where('status', Appointment::STATUS_COMPLETED)
->whereNotNull('started_at')
->whereNotNull('completed_at')
->where('completed_at', '>=', $monthStart)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId));
$avgChair = null;
$samples = (clone $chairQuery)->limit(200)->get(['started_at', 'completed_at']);
if ($samples->isNotEmpty()) {
$avgChair = round($samples->avg(fn ($a) => $a->started_at->diffInMinutes($a->completed_at)), 1);
}
$imaging = DentalImage::owned($ownerRef)
->where('organization_id', $organization->id)
->where('captured_at', '>=', $monthStart)
->selectRaw('modality, count(*) as total')
->groupBy('modality')
->orderByDesc('total')
->get();
return [
'procedures_today' => $proceduresToday,
'procedures_month' => $proceduresMonth,
'revenue_by_code' => $revenueByCode,
'unfinished_items' => $unfinished,
'avg_chair_minutes' => $avgChair,
'imaging_by_modality' => $imaging,
];
}
/**
* @return list<array{code: string, severity: string, message: string}>
*/
public function alertsForPatient(Organization $organization, Patient $patient, string $ownerRef): array
{
$alerts = [];
$plan = DentalTreatmentPlan::owned($ownerRef)
->where('organization_id', $organization->id)
->where('patient_id', $patient->id)
->whereIn('status', [
DentalTreatmentPlan::STATUS_ACCEPTED,
DentalTreatmentPlan::STATUS_IN_PROGRESS,
])
->with('items')
->latest('id')
->first();
if ($plan && $plan->accepted_at && $plan->accepted_at->lt(now()->subDays(30))) {
$open = $plan->items->whereIn('status', [
DentalPlanItem::STATUS_ACCEPTED,
DentalPlanItem::STATUS_PROPOSED,
])->count();
if ($open > 0) {
$alerts[] = [
'code' => 'dental.plan.stale',
'severity' => 'warning',
'message' => "Accepted treatment plan has {$open} unfinished item(s) older than 30 days.",
];
}
}
if ($plan) {
$high = $plan->items
->where('priority', '<=', 20)
->whereIn('status', [DentalPlanItem::STATUS_ACCEPTED, DentalPlanItem::STATUS_PROPOSED]);
foreach ($high->take(3) as $item) {
$tooth = $item->tooth_code ? " tooth {$item->tooth_code}" : '';
$alerts[] = [
'code' => 'dental.plan.high_priority',
'severity' => 'warning',
'message' => "High-priority planned: {$item->label}{$tooth}.",
];
}
}
return $alerts;
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Services\Care\Dentistry;
/**
* FDI adult dentition helpers and catalog labels for dentistry.
*/
class DentalCatalog
{
/** @return list<string> */
public static function adultToothCodes(): array
{
$codes = [];
foreach ([18, 17, 16, 15, 14, 13, 12, 11, 21, 22, 23, 24, 25, 26, 27, 28] as $n) {
$codes[] = (string) $n;
}
foreach ([48, 47, 46, 45, 44, 43, 42, 41, 31, 32, 33, 34, 35, 36, 37, 38] as $n) {
$codes[] = (string) $n;
}
return $codes;
}
/**
* @return array{upper: list<string>, lower: list<string>}
*/
public static function odontogramRows(): 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 list<string> */
public static function surfaces(): array
{
return ['M', 'O', 'D', 'B', 'L', 'I'];
}
/** @return array<string, string> */
public static function conditions(): array
{
return [
'caries' => 'Caries',
'filled' => 'Filled',
'crown' => 'Crown',
'root_canal' => 'Root canal',
'fracture' => 'Fracture',
'watch' => 'Watch',
'missing' => 'Missing',
'implant' => 'Implant',
];
}
/** @return array<string, string> */
public static function toothStatuses(): array
{
return [
'present' => 'Present',
'missing' => 'Missing',
'extracted' => 'Extracted',
'implant' => 'Implant',
];
}
/** @return array<string, string> */
public static function modalities(): array
{
return [
'pa' => 'Periapical (PA)',
'bw' => 'Bitewing (BW)',
'opg' => 'OPG / panoramic',
'photo' => 'Clinical photo',
];
}
/** @return array<string, array{label: string, amount_minor: int, type: string}> */
public static function defaultServices(): array
{
return [
'den.consult' => ['label' => 'Dental consultation', 'amount_minor' => 5000, 'type' => 'consultation'],
'den.exam' => ['label' => 'Dental examination', 'amount_minor' => 4000, 'type' => 'consultation'],
'den.cleaning' => ['label' => 'Scaling / polishing', 'amount_minor' => 8000, 'type' => 'procedure'],
'den.fill' => ['label' => 'Filling', 'amount_minor' => 12000, 'type' => 'procedure'],
'den.extraction' => ['label' => 'Extraction', 'amount_minor' => 15000, 'type' => 'procedure'],
'den.rct' => ['label' => 'Root canal treatment', 'amount_minor' => 35000, 'type' => 'procedure'],
'den.crown' => ['label' => 'Crown', 'amount_minor' => 45000, 'type' => 'procedure'],
'den.bridge' => ['label' => 'Bridge unit', 'amount_minor' => 40000, 'type' => 'procedure'],
'den.implant_consult' => ['label' => 'Implant consultation', 'amount_minor' => 10000, 'type' => 'consultation'],
'den.perio' => ['label' => 'Periodontal treatment', 'amount_minor' => 20000, 'type' => 'procedure'],
'den.xray_pa' => ['label' => 'Periapical X-ray', 'amount_minor' => 3000, 'type' => 'imaging'],
'den.xray_bw' => ['label' => 'Bitewing X-ray', 'amount_minor' => 3500, 'type' => 'imaging'],
'den.xray_opg' => ['label' => 'OPG / panoramic X-ray', 'amount_minor' => 8000, 'type' => 'imaging'],
'den.photo' => ['label' => 'Clinical photography', 'amount_minor' => 2000, 'type' => 'imaging'],
];
}
public static function isValidToothCode(string $code): bool
{
return in_array($code, self::adultToothCodes(), true);
}
}
@@ -0,0 +1,170 @@
<?php
namespace App\Services\Care\Dentistry;
use App\Models\DentalChart;
use App\Models\DentalChartEvent;
use App\Models\DentalTooth;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Visit;
use App\Services\Care\AuditLogger;
use Illuminate\Support\Facades\DB;
class DentalChartService
{
public function chartForPatient(Organization $organization, Patient $patient, string $ownerRef): DentalChart
{
$chart = DentalChart::owned($ownerRef)
->where('organization_id', $organization->id)
->where('patient_id', $patient->id)
->with('teeth')
->first();
if ($chart) {
return $chart;
}
return DB::transaction(function () use ($organization, $patient, $ownerRef) {
$chart = DentalChart::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'patient_id' => $patient->id,
'notation' => 'fdi',
'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' => [],
]);
}
return $chart->fresh('teeth');
});
}
/**
* @param array{status?: string, surfaces?: list<string>, conditions?: list<string>, notes?: ?string} $data
*/
public function updateTooth(
DentalChart $chart,
string $toothCode,
array $data,
string $ownerRef,
?string $actorRef = null,
?Visit $visit = null,
): DentalTooth {
if (! DentalCatalog::isValidToothCode($toothCode)) {
throw new \InvalidArgumentException("Invalid FDI tooth code [{$toothCode}].");
}
$tooth = $chart->teeth()->where('tooth_code', $toothCode)->first();
if (! $tooth) {
$tooth = DentalTooth::create([
'dental_chart_id' => $chart->id,
'tooth_code' => $toothCode,
'status' => DentalTooth::STATUS_PRESENT,
'surfaces' => [],
'conditions' => [],
]);
}
$before = [
'status' => $tooth->status,
'surfaces' => $tooth->surfaces ?? [],
'conditions' => $tooth->conditions ?? [],
'notes' => $tooth->notes,
];
$status = $data['status'] ?? $tooth->status;
if (! array_key_exists($status, DentalCatalog::toothStatuses())) {
throw new \InvalidArgumentException("Invalid tooth status [{$status}].");
}
$surfaces = array_values(array_intersect(
array_map('strtoupper', $data['surfaces'] ?? ($tooth->surfaces ?? [])),
DentalCatalog::surfaces(),
));
$allowedConditions = array_keys(DentalCatalog::conditions());
$conditions = array_values(array_intersect(
$data['conditions'] ?? ($tooth->conditions ?? []),
$allowedConditions,
));
if (in_array($status, [DentalTooth::STATUS_MISSING, DentalTooth::STATUS_EXTRACTED], true)) {
$surfaces = [];
if (! in_array('missing', $conditions, true)) {
$conditions[] = 'missing';
}
}
$tooth->update([
'status' => $status,
'surfaces' => $surfaces,
'conditions' => $conditions,
'notes' => $data['notes'] ?? $tooth->notes,
]);
$chart->update([
'charted_at' => now(),
'charted_by' => $actorRef ?? $ownerRef,
]);
$after = [
'status' => $tooth->status,
'surfaces' => $tooth->surfaces ?? [],
'conditions' => $tooth->conditions ?? [],
'notes' => $tooth->notes,
];
DentalChartEvent::create([
'owner_ref' => $ownerRef,
'organization_id' => $chart->organization_id,
'dental_chart_id' => $chart->id,
'patient_id' => $chart->patient_id,
'visit_id' => $visit?->id,
'tooth_code' => $toothCode,
'event_type' => 'tooth_updated',
'payload' => ['before' => $before, 'after' => $after],
'recorded_by' => $actorRef ?? $ownerRef,
'recorded_at' => now(),
]);
AuditLogger::record(
$ownerRef,
'dental.chart.tooth_updated',
$chart->organization_id,
$actorRef,
DentalTooth::class,
$tooth->id,
['tooth_code' => $toothCode],
);
return $tooth->fresh();
}
/**
* @return array<string, array{status: string, surfaces: list<string>, conditions: list<string>, notes: ?string}>
*/
public function teethMap(DentalChart $chart): array
{
$map = [];
foreach ($chart->teeth as $tooth) {
$map[$tooth->tooth_code] = [
'status' => $tooth->status,
'surfaces' => $tooth->surfaces ?? [],
'conditions' => $tooth->conditions ?? [],
'notes' => $tooth->notes,
];
}
return $map;
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Services\Care\Dentistry;
use App\Models\DentalImage;
use App\Models\Organization;
use App\Models\PatientAttachment;
use App\Models\Visit;
use App\Services\Care\AuditLogger;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class DentalImagingService
{
/**
* @param array{modality: string, tooth_codes?: list<string>, caption?: ?string, bill_fee?: bool} $meta
*/
public function upload(
Organization $organization,
Visit $visit,
UploadedFile $file,
array $meta,
string $ownerRef,
?string $actorRef = null,
): DentalImage {
$visit->loadMissing('patient');
$modality = (string) ($meta['modality'] ?? '');
if (! array_key_exists($modality, DentalCatalog::modalities())) {
throw new \InvalidArgumentException("Invalid imaging modality [{$modality}].");
}
$toothCodes = array_values(array_filter(
$meta['tooth_codes'] ?? [],
fn ($c) => DentalCatalog::isValidToothCode((string) $c),
));
$path = $file->store("care/patients/{$visit->patient_id}/dental", 'public');
$attachment = PatientAttachment::create([
'owner_ref' => $ownerRef,
'patient_id' => $visit->patient_id,
'file_path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'document_type' => 'dental_imaging_'.$modality,
'uploaded_by' => $actorRef ?? $ownerRef,
]);
$image = DentalImage::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'patient_id' => $visit->patient_id,
'visit_id' => $visit->id,
'patient_attachment_id' => $attachment->id,
'modality' => $modality,
'tooth_codes' => $toothCodes,
'caption' => $meta['caption'] ?? null,
'uploaded_by' => $actorRef ?? $ownerRef,
'captured_at' => now(),
]);
AuditLogger::record(
$ownerRef,
'dental.image.uploaded',
$organization->id,
$actorRef,
DentalImage::class,
$image->id,
);
return $image->fresh('attachment');
}
public function url(DentalImage $image): ?string
{
$path = $image->attachment?->file_path;
if (! $path) {
return null;
}
return Storage::disk('public')->url($path);
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Services\Care\Dentistry;
use App\Models\DentalPlanItem;
use App\Models\DentalProcedure;
use App\Models\DentalTreatmentPlan;
use App\Models\Organization;
use App\Models\Visit;
use App\Services\Care\AuditLogger;
use App\Services\Care\BillService;
use App\Services\Care\SpecialtyShellService;
class DentalProcedureService
{
public function __construct(
protected SpecialtyShellService $shell,
protected BillService $bills,
protected DentalTreatmentPlanService $plans,
) {}
/**
* @param array{
* procedure_code: string,
* tooth_code?: ?string,
* surfaces?: list<string>,
* anesthesia?: ?string,
* notes?: ?string,
* plan_item_id?: ?int,
* bill?: bool
* } $data
*/
public function complete(
Organization $organization,
Visit $visit,
array $data,
string $ownerRef,
?string $actorRef = null,
): DentalProcedure {
$visit->loadMissing('patient');
$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}].");
}
$tooth = $data['tooth_code'] ?? null;
if ($tooth && ! DentalCatalog::isValidToothCode($tooth)) {
throw new \InvalidArgumentException("Invalid FDI tooth code [{$tooth}].");
}
$surfaces = array_values(array_intersect(
array_map('strtoupper', $data['surfaces'] ?? []),
DentalCatalog::surfaces(),
));
$planItem = null;
if (! empty($data['plan_item_id'])) {
$planItem = DentalPlanItem::query()->find((int) $data['plan_item_id']);
if ($planItem && $planItem->plan?->patient_id !== $visit->patient_id) {
throw new \InvalidArgumentException('Plan item does not belong to this patient.');
}
}
$label = $service['label'];
if ($tooth) {
$label .= ' · tooth '.$tooth;
if ($surfaces !== []) {
$label .= ' ('.implode('', $surfaces).')';
}
}
$amount = (int) ($service['amount_minor'] ?? 0);
$procedure = DentalProcedure::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'patient_id' => $visit->patient_id,
'visit_id' => $visit->id,
'plan_item_id' => $planItem?->id,
'tooth_code' => $tooth,
'surfaces' => $surfaces,
'procedure_code' => $code,
'label' => $label,
'anesthesia' => $data['anesthesia'] ?? null,
'status' => DentalProcedure::STATUS_COMPLETED,
'amount_minor' => $amount,
'bill_line_item_id' => null,
'provider_ref' => $actorRef ?? $ownerRef,
'notes' => $data['notes'] ?? null,
'performed_at' => now(),
]);
if ($data['bill'] ?? true) {
$bill = $this->bills->generateFromVisit($visit, $ownerRef, $actorRef);
$bill = $this->bills->addManualLineItem($bill, $ownerRef, [
'type' => $service['type'] ?? 'procedure',
'description' => $label,
'quantity' => 1,
'unit_price_minor' => $amount,
'source_type' => 'dental_procedure',
'source_id' => $procedure->id,
], $actorRef);
$billLineId = $bill->lineItems()->where('source_type', 'dental_procedure')->where('source_id', $procedure->id)->value('id');
$procedure->update(['bill_line_item_id' => $billLineId]);
if ($planItem && $billLineId) {
$planItem->update(['bill_line_item_id' => $billLineId]);
}
}
if ($planItem) {
$this->plans->markItemDone($planItem);
$plan = $planItem->plan;
if ($plan && $plan->status === DentalTreatmentPlan::STATUS_ACCEPTED) {
$plan->update(['status' => DentalTreatmentPlan::STATUS_IN_PROGRESS]);
}
}
AuditLogger::record(
$ownerRef,
'dental.procedure.completed',
$organization->id,
$actorRef,
DentalProcedure::class,
$procedure->id,
);
return $procedure->fresh();
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Services\Care\Dentistry;
use App\Models\DentalPlanItem;
use App\Models\DentalTreatmentPlan;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Visit;
use App\Services\Care\AuditLogger;
use App\Services\Care\SpecialtyShellService;
class DentalTreatmentPlanService
{
public function __construct(
protected SpecialtyShellService $shell,
) {}
public function activePlanForPatient(Organization $organization, Patient $patient, string $ownerRef): ?DentalTreatmentPlan
{
return DentalTreatmentPlan::owned($ownerRef)
->where('organization_id', $organization->id)
->where('patient_id', $patient->id)
->whereIn('status', [
DentalTreatmentPlan::STATUS_DRAFT,
DentalTreatmentPlan::STATUS_ACCEPTED,
DentalTreatmentPlan::STATUS_IN_PROGRESS,
])
->with('items')
->latest('id')
->first();
}
public function ensurePlan(
Organization $organization,
Patient $patient,
string $ownerRef,
?Visit $visit = null,
?string $actorRef = null,
): DentalTreatmentPlan {
$plan = $this->activePlanForPatient($organization, $patient, $ownerRef);
if ($plan) {
return $plan;
}
$plan = DentalTreatmentPlan::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'patient_id' => $patient->id,
'visit_id' => $visit?->id,
'status' => DentalTreatmentPlan::STATUS_DRAFT,
'title' => 'Treatment plan',
'created_by' => $actorRef ?? $ownerRef,
]);
AuditLogger::record($ownerRef, 'dental.plan.created', $organization->id, $actorRef, DentalTreatmentPlan::class, $plan->id);
return $plan->fresh('items');
}
/**
* @param array{tooth_code?: ?string, surfaces?: list<string>, procedure_code: string, priority?: int, notes?: ?string} $data
*/
public function addItem(
DentalTreatmentPlan $plan,
Organization $organization,
array $data,
string $ownerRef,
?string $actorRef = null,
): DentalPlanItem {
$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}].");
}
$tooth = $data['tooth_code'] ?? null;
if ($tooth && ! DentalCatalog::isValidToothCode($tooth)) {
throw new \InvalidArgumentException("Invalid FDI tooth code [{$tooth}].");
}
$surfaces = array_values(array_intersect(
array_map('strtoupper', $data['surfaces'] ?? []),
DentalCatalog::surfaces(),
));
$item = DentalPlanItem::create([
'treatment_plan_id' => $plan->id,
'tooth_code' => $tooth,
'surfaces' => $surfaces,
'procedure_code' => $code,
'label' => $service['label'],
'priority' => (int) ($data['priority'] ?? 50),
'status' => $plan->status === DentalTreatmentPlan::STATUS_DRAFT
? DentalPlanItem::STATUS_PROPOSED
: DentalPlanItem::STATUS_ACCEPTED,
'estimate_minor' => (int) ($service['amount_minor'] ?? 0),
'notes' => $data['notes'] ?? null,
]);
$this->recalculateEstimate($plan);
AuditLogger::record($ownerRef, 'dental.plan.item_added', $organization->id, $actorRef, DentalPlanItem::class, $item->id);
return $item;
}
public function accept(DentalTreatmentPlan $plan, string $ownerRef, ?string $actorRef = null): DentalTreatmentPlan
{
$plan->update([
'status' => DentalTreatmentPlan::STATUS_ACCEPTED,
'accepted_at' => now(),
]);
$plan->items()
->where('status', DentalPlanItem::STATUS_PROPOSED)
->update(['status' => DentalPlanItem::STATUS_ACCEPTED]);
AuditLogger::record($ownerRef, 'dental.plan.accepted', $plan->organization_id, $actorRef, DentalTreatmentPlan::class, $plan->id);
return $plan->fresh('items');
}
public function markItemDone(DentalPlanItem $item): DentalPlanItem
{
$item->update(['status' => DentalPlanItem::STATUS_DONE]);
$plan = $item->plan;
if ($plan && $plan->items()->whereNotIn('status', [DentalPlanItem::STATUS_DONE, DentalPlanItem::STATUS_CANCELLED])->doesntExist()) {
$plan->update([
'status' => DentalTreatmentPlan::STATUS_COMPLETED,
'completed_at' => now(),
]);
} elseif ($plan && in_array($plan->status, [DentalTreatmentPlan::STATUS_ACCEPTED, DentalTreatmentPlan::STATUS_DRAFT], true)) {
$plan->update(['status' => DentalTreatmentPlan::STATUS_IN_PROGRESS]);
}
return $item->fresh();
}
public function recalculateEstimate(DentalTreatmentPlan $plan): void
{
$total = (int) $plan->items()
->whereNotIn('status', [DentalPlanItem::STATUS_CANCELLED])
->sum('estimate_minor');
$plan->update(['estimate_minor' => $total]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Services\Care\Dentistry;
use App\Models\DentalVisitNote;
use App\Models\Organization;
use App\Models\Visit;
use App\Services\Care\AuditLogger;
class DentalVisitNoteService
{
/**
* @param array{chief_complaint?: ?string, soft_tissue?: ?string, occlusion?: ?string, notes?: ?string} $data
*/
public function upsert(
Organization $organization,
Visit $visit,
array $data,
string $ownerRef,
?string $actorRef = null,
): DentalVisitNote {
$visit->loadMissing('patient');
$note = DentalVisitNote::owned($ownerRef)
->where('visit_id', $visit->id)
->first();
$attributes = [
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'visit_id' => $visit->id,
'patient_id' => $visit->patient_id,
'chief_complaint' => $data['chief_complaint'] ?? null,
'soft_tissue' => $data['soft_tissue'] ?? null,
'occlusion' => $data['occlusion'] ?? null,
'notes' => $data['notes'] ?? null,
'recorded_by' => $actorRef ?? $ownerRef,
'recorded_at' => now(),
];
if ($note) {
$note->update($attributes);
} else {
$note = DentalVisitNote::create($attributes);
}
AuditLogger::record($ownerRef, 'dental.notes.saved', $organization->id, $actorRef, DentalVisitNote::class, $note->id);
return $note->fresh();
}
}
@@ -163,6 +163,10 @@ class SpecialtyModuleService
return false;
}
if (app(CarePermissions::class)->isAdmin($member)) {
return true;
}
$roles = $definition['roles'] ?? [];
if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) {
return false;
+25 -4
View File
@@ -72,12 +72,20 @@ class SpecialtyShellService
*/
public function provisionedServices(Organization $organization, string $moduleKey): array
{
$catalog = collect($this->catalogServices($moduleKey))->keyBy(fn ($s) => (string) ($s['code'] ?? ''));
$stored = data_get($organization->settings, "specialty_module_provisioning.{$moduleKey}.services");
if (is_array($stored) && $stored !== []) {
return array_values($stored);
if (is_array($stored)) {
foreach ($stored as $service) {
$code = (string) ($service['code'] ?? '');
if ($code === '') {
continue;
}
$prior = $catalog->get($code, []);
$catalog->put($code, array_merge(is_array($prior) ? $prior : [], $service));
}
}
return $this->catalogServices($moduleKey);
return $catalog->filter(fn ($s, $code) => $code !== '')->values()->all();
}
/**
@@ -271,7 +279,20 @@ class SpecialtyShellService
$clinicalOpen = match ($moduleKey) {
'emergency' => $clinical->countOpenByType($organization, 'emergency', 'triage', $branchScope),
'blood_bank' => $clinical->countOpenByType($organization, 'blood_bank', 'blood_request', $branchScope),
'dentistry' => $clinical->countOpenByType($organization, 'dentistry', 'odontogram', $branchScope),
'dentistry' => \App\Models\DentalPlanItem::query()
->whereHas('plan', function ($q) use ($organization, $ownerRef) {
$q->owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('status', [
\App\Models\DentalTreatmentPlan::STATUS_ACCEPTED,
\App\Models\DentalTreatmentPlan::STATUS_IN_PROGRESS,
]);
})
->whereIn('status', [
\App\Models\DentalPlanItem::STATUS_ACCEPTED,
\App\Models\DentalPlanItem::STATUS_PROPOSED,
])
->count(),
default => 0,
};
-16
View File
@@ -20,8 +20,6 @@ return [
'inventory' => 'inventory_note',
],
'dentistry' => [
'odontogram' => 'odontogram',
'clinical_notes' => 'clinical_note',
],
'ophthalmology' => [
'exam' => 'eye_exam',
@@ -145,20 +143,6 @@ return [
],
],
'dentistry' => [
'odontogram' => [
['name' => 'teeth_affected', 'label' => 'Teeth affected (FDI numbers, comma-separated)', 'type' => 'text', 'required' => true],
['name' => 'findings', 'label' => 'Chart findings', 'type' => 'textarea', 'required' => true, 'rows' => 3],
['name' => 'planned_procedures', 'label' => 'Planned procedures', 'type' => 'textarea', 'rows' => 2],
['name' => 'completed_procedures', 'label' => 'Completed this visit', 'type' => 'textarea', 'rows' => 2],
['name' => 'anesthesia', 'label' => 'Anesthesia', 'type' => 'select', 'options' => ['None', 'Local', 'Sedation', 'GA']],
['name' => 'occlusion_notes', 'label' => 'Occlusion / bite notes', 'type' => 'text'],
],
'clinical_note' => [
['name' => 'history', 'label' => 'History', 'type' => 'textarea', 'required' => true, 'rows' => 3],
['name' => 'examination', 'label' => 'Examination', 'type' => 'textarea', 'rows' => 3],
['name' => 'working_diagnosis', 'label' => 'Diagnosis', 'type' => 'text'],
['name' => 'plan', 'label' => 'Plan', 'type' => 'textarea', 'rows' => 2],
],
],
'ophthalmology' => [
'eye_exam' => [
+14 -2
View File
@@ -101,15 +101,27 @@ return [
],
'services' => [
['code' => 'den.consult', 'label' => 'Dental consultation', 'amount_minor' => 5000, 'type' => 'consultation'],
['code' => 'den.exam', 'label' => 'Dental examination', 'amount_minor' => 4000, 'type' => 'consultation'],
['code' => 'den.cleaning', 'label' => 'Scaling / polishing', 'amount_minor' => 8000, 'type' => 'procedure'],
['code' => 'den.fill', 'label' => 'Filling', 'amount_minor' => 12000, 'type' => 'procedure'],
['code' => 'den.extraction', 'label' => 'Extraction', 'amount_minor' => 15000, 'type' => 'procedure'],
['code' => 'den.xray', 'label' => 'Dental X-ray', 'amount_minor' => 4000, 'type' => 'imaging'],
['code' => 'den.rct', 'label' => 'Root canal treatment', 'amount_minor' => 35000, 'type' => 'procedure'],
['code' => 'den.crown', 'label' => 'Crown', 'amount_minor' => 45000, 'type' => 'procedure'],
['code' => 'den.bridge', 'label' => 'Bridge unit', 'amount_minor' => 40000, 'type' => 'procedure'],
['code' => 'den.implant_consult', 'label' => 'Implant consultation', 'amount_minor' => 10000, 'type' => 'consultation'],
['code' => 'den.perio', 'label' => 'Periodontal treatment', 'amount_minor' => 20000, 'type' => 'procedure'],
['code' => 'den.xray_pa', 'label' => 'Periapical X-ray', 'amount_minor' => 3000, 'type' => 'imaging'],
['code' => 'den.xray_bw', 'label' => 'Bitewing X-ray', 'amount_minor' => 3500, 'type' => 'imaging'],
['code' => 'den.xray_opg', 'label' => 'OPG / panoramic X-ray', 'amount_minor' => 8000, 'type' => 'imaging'],
['code' => 'den.photo', 'label' => 'Clinical photography', 'amount_minor' => 2000, 'type' => 'imaging'],
],
'workspace_tabs' => [
'overview' => 'Overview',
'odontogram' => 'Chart',
'clinical_notes' => 'Clinical notes',
'plan' => 'Plan',
'treat' => 'Treat',
'notes' => 'Notes',
'imaging' => 'Imaging',
'orders' => 'Orders',
'billing' => 'Billing',
'documents' => 'Documents',
@@ -0,0 +1,185 @@
<?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_dental_charts')) {
Schema::create('care_dental_charts', 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->string('notation', 16)->default('fdi');
$table->timestamp('charted_at')->nullable();
$table->string('charted_by')->nullable();
$table->timestamps();
$table->unique(['patient_id']);
$table->index(['organization_id', 'patient_id']);
});
}
if (! Schema::hasTable('care_dental_teeth')) {
Schema::create('care_dental_teeth', function (Blueprint $table) {
$table->id();
$table->foreignId('dental_chart_id')->constrained('care_dental_charts')->cascadeOnDelete();
$table->string('tooth_code', 8);
$table->string('status', 32)->default('present');
$table->json('surfaces')->nullable();
$table->json('conditions')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
$table->unique(['dental_chart_id', 'tooth_code']);
});
}
if (! Schema::hasTable('care_dental_chart_events')) {
Schema::create('care_dental_chart_events', 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('dental_chart_id')->constrained('care_dental_charts')->cascadeOnDelete();
$table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete();
$table->foreignId('visit_id')->nullable()->constrained('care_visits')->nullOnDelete();
$table->string('tooth_code', 8)->nullable();
$table->string('event_type', 64);
$table->json('payload')->nullable();
$table->string('recorded_by')->nullable();
$table->timestamp('recorded_at')->nullable();
$table->timestamps();
$table->index(['dental_chart_id', 'recorded_at']);
$table->index(['visit_id']);
});
}
if (! Schema::hasTable('care_dental_treatment_plans')) {
Schema::create('care_dental_treatment_plans', 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->string('status', 32)->default('draft')->index();
$table->string('title')->nullable();
$table->unsignedInteger('estimate_minor')->default(0);
$table->timestamp('accepted_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->string('created_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['organization_id', 'patient_id', 'status']);
});
}
if (! Schema::hasTable('care_dental_plan_items')) {
Schema::create('care_dental_plan_items', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('treatment_plan_id')->constrained('care_dental_treatment_plans')->cascadeOnDelete();
$table->string('tooth_code', 8)->nullable();
$table->json('surfaces')->nullable();
$table->string('procedure_code', 64);
$table->string('label');
$table->unsignedSmallInteger('priority')->default(50);
$table->string('status', 32)->default('proposed')->index();
$table->unsignedInteger('estimate_minor')->default(0);
$table->foreignId('bill_line_item_id')->nullable()->constrained('care_bill_line_items')->nullOnDelete();
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['treatment_plan_id', 'status']);
});
}
if (! Schema::hasTable('care_dental_procedures')) {
Schema::create('care_dental_procedures', 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')->constrained('care_visits')->cascadeOnDelete();
$table->foreignId('plan_item_id')->nullable()->constrained('care_dental_plan_items')->nullOnDelete();
$table->string('tooth_code', 8)->nullable();
$table->json('surfaces')->nullable();
$table->string('procedure_code', 64);
$table->string('label');
$table->string('anesthesia', 32)->nullable();
$table->string('status', 32)->default('completed')->index();
$table->unsignedInteger('amount_minor')->default(0);
$table->foreignId('bill_line_item_id')->nullable()->constrained('care_bill_line_items')->nullOnDelete();
$table->string('provider_ref')->nullable();
$table->text('notes')->nullable();
$table->timestamp('performed_at')->nullable();
$table->timestamps();
$table->index(['visit_id', 'status']);
$table->index(['organization_id', 'procedure_code']);
});
}
if (! Schema::hasTable('care_dental_images')) {
Schema::create('care_dental_images', 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('patient_attachment_id')->constrained('care_patient_attachments')->cascadeOnDelete();
$table->string('modality', 32);
$table->json('tooth_codes')->nullable();
$table->string('caption')->nullable();
$table->string('uploaded_by')->nullable();
$table->timestamp('captured_at')->nullable();
$table->timestamps();
$table->index(['visit_id', 'modality']);
$table->index(['patient_id', 'captured_at']);
});
}
if (! Schema::hasTable('care_dental_visit_notes')) {
Schema::create('care_dental_visit_notes', 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('visit_id')->constrained('care_visits')->cascadeOnDelete();
$table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete();
$table->text('chief_complaint')->nullable();
$table->text('soft_tissue')->nullable();
$table->text('occlusion')->nullable();
$table->text('notes')->nullable();
$table->string('recorded_by')->nullable();
$table->timestamp('recorded_at')->nullable();
$table->timestamps();
$table->unique(['visit_id']);
});
}
}
public function down(): void
{
Schema::dropIfExists('care_dental_visit_notes');
Schema::dropIfExists('care_dental_images');
Schema::dropIfExists('care_dental_procedures');
Schema::dropIfExists('care_dental_plan_items');
Schema::dropIfExists('care_dental_treatment_plans');
Schema::dropIfExists('care_dental_chart_events');
Schema::dropIfExists('care_dental_teeth');
Schema::dropIfExists('care_dental_charts');
}
};
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dental chart · {{ $patient->fullName() }}</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; color: #0f172a; padding: 24px; }
h1 { font-size: 18px; margin: 0 0 4px; }
h2 { font-size: 14px; margin: 24px 0 8px; }
.meta { color: #64748b; font-size: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { border: 1px solid #e2e8f0; padding: 6px 8px; text-align: left; }
th { background: #f8fafc; }
.teeth { display: flex; flex-wrap: wrap; gap: 4px; margin: 8px 0; }
.tooth { width: 36px; text-align: center; border: 1px solid #cbd5e1; border-radius: 6px; padding: 6px 0; font-size: 11px; font-weight: 600; }
.tooth.finding { background: #fffbeb; border-color: #fcd34d; }
@media print { button { display: none; } }
</style>
</head>
<body>
<button onclick="window.print()" style="margin-bottom:16px;padding:8px 12px;">Print</button>
<h1>{{ $patient->fullName() }}</h1>
<p class="meta">
{{ $patient->patient_number }}
· Visit {{ $visit->uuid }}
· Charted {{ $chart->charted_at?->format('d M Y H:i') ?? '—' }}
</p>
<h2>Odontogram (FDI)</h2>
@foreach (['upper' => 'Upper', 'lower' => 'Lower'] as $key => $label)
<p class="meta">{{ $label }}</p>
<div class="teeth">
@foreach ($rows[$key] as $code)
@php $t = $teethMap[$code] ?? []; $finding = ($t['status'] ?? 'present') !== 'present' || !empty($t['conditions']); @endphp
<div class="tooth {{ $finding ? 'finding' : '' }}">{{ $code }}</div>
@endforeach
</div>
@endforeach
<h2>Findings</h2>
<table>
<thead>
<tr><th>Tooth</th><th>Status</th><th>Surfaces</th><th>Conditions</th><th>Notes</th></tr>
</thead>
<tbody>
@foreach ($teethMap as $code => $t)
@if (($t['status'] ?? 'present') !== 'present' || ! empty($t['conditions']) || ! empty($t['notes']))
<tr>
<td>{{ $code }}</td>
<td>{{ $t['status'] ?? 'present' }}</td>
<td>{{ implode('', $t['surfaces'] ?? []) }}</td>
<td>
@foreach ($t['conditions'] ?? [] as $c)
{{ $conditions[$c] ?? $c }}@if(!$loop->last), @endif
@endforeach
</td>
<td>{{ $t['notes'] ?? '' }}</td>
</tr>
@endif
@endforeach
</tbody>
</table>
@if ($plan)
<h2>Treatment plan ({{ $plan->status }})</h2>
<table>
<thead>
<tr><th>Procedure</th><th>Tooth</th><th>Status</th><th>Estimate</th></tr>
</thead>
<tbody>
@foreach ($plan->items as $item)
<tr>
<td>{{ $item->label }}</td>
<td>{{ $item->tooth_code ?? '—' }}</td>
<td>{{ $item->status }}</td>
<td>{{ number_format($item->estimate_minor / 100, 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</body>
</html>
@@ -0,0 +1,72 @@
<x-app-layout title="Dentistry reports">
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-xl font-semibold text-slate-900">Dentistry reports</h1>
<p class="mt-1 text-sm text-slate-500">Procedures, revenue, unfinished plans, and imaging volume.</p>
</div>
<a href="{{ route('care.queue.index') }}" class="text-sm font-medium text-indigo-600"> Back to queue</a>
</div>
<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>
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">{{ number_format($report['unfinished_items']) }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Avg chair time (month)</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">
{{ $report['avg_chair_minutes'] !== null ? $report['avg_chair_minutes'].' min' : '—' }}
</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Imaging studies (month)</p>
<p class="mt-2 text-2xl font-semibold tabular-nums text-slate-900">
{{ number_format($report['imaging_by_modality']->sum('total')) }}
</p>
</div>
</div>
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Procedures today</h2>
<ul class="mt-3 space-y-1 text-sm">
@forelse ($report['procedures_today'] as $row)
<li class="flex justify-between gap-3">
<span>{{ $row->label }} × {{ $row->total }}</span>
<span class="tabular-nums">{{ $currency }} {{ number_format($row->amount_minor / 100, 2) }}</span>
</li>
@empty
<li class="text-slate-500">No procedures today.</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">Procedures this month</h2>
<ul class="mt-3 space-y-1 text-sm">
@forelse ($report['procedures_month'] as $row)
<li class="flex justify-between gap-3">
<span>{{ $row->label }} × {{ $row->total }}</span>
<span class="tabular-nums">{{ $currency }} {{ number_format($row->amount_minor / 100, 2) }}</span>
</li>
@empty
<li class="text-slate-500">No procedures this month.</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 (month)</h2>
<ul class="mt-3 space-y-1 text-sm">
@forelse ($report['imaging_by_modality'] as $row)
<li class="flex justify-between gap-3">
<span>{{ $modalities[$row->modality] ?? $row->modality }}</span>
<span class="tabular-nums">{{ $row->total }}</span>
</li>
@empty
<li class="text-slate-500">No imaging this month.</li>
@endforelse
</ul>
</section>
</div>
</x-app-layout>
@@ -0,0 +1,77 @@
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Dental imaging</h3>
<p class="mt-0.5 text-xs text-slate-500">Upload PA, bitewing, OPG, or clinical photos linked to teeth.</p>
<form method="POST" action="{{ route('care.specialty.dentistry.images', $workspaceVisit) }}" enctype="multipart/form-data" class="mt-4 space-y-3">
@csrf
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="block text-xs font-medium text-slate-600">File</label>
<input type="file" name="attachment" required accept=".pdf,.jpg,.jpeg,.png,.webp" class="mt-1 block w-full text-sm">
</div>
<div>
<label class="block text-xs font-medium text-slate-600">Modality</label>
<select name="modality" required class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
@foreach ($dentalModalities as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-slate-600">Caption</label>
<input type="text" name="caption" 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">Teeth</p>
<div class="mt-2 flex max-h-28 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="inline-flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="add_fee" value="1" class="rounded border-slate-300 text-indigo-600" checked>
Add imaging fee to bill
</label>
</div>
</div>
<button type="submit" class="btn-primary">Upload image</button>
</form>
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@forelse ($dentalImages as $image)
@php
$url = $image->attachment?->file_path
? \Illuminate\Support\Facades\Storage::disk('public')->url($image->attachment->file_path)
: null;
@endphp
<div class="overflow-hidden rounded-xl border border-slate-100 bg-slate-50">
@if ($url && str_starts_with((string) $image->attachment?->mime_type, 'image/'))
<a href="{{ $url }}" target="_blank">
<img src="{{ $url }}" alt="{{ $image->caption ?: $image->modality }}" class="h-32 w-full object-cover">
</a>
@elseif ($url)
<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>
</div>
@empty
<p class="text-sm text-slate-500 sm:col-span-2">No images yet.</p>
@endforelse
</div>
</section>
@@ -0,0 +1,25 @@
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Dental visit notes</h3>
<p class="mt-0.5 text-xs text-slate-500">Short clinical notes for this visit not a GP SOAP form.</p>
<form method="POST" action="{{ route('care.specialty.dentistry.notes', $workspaceVisit) }}" class="mt-4 space-y-3">
@csrf
<div>
<label class="block text-xs font-medium text-slate-600">Chief complaint</label>
<textarea name="chief_complaint" rows="2" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">{{ old('chief_complaint', $dentalVisitNote?->chief_complaint) }}</textarea>
</div>
<div>
<label class="block text-xs font-medium text-slate-600">Soft tissue</label>
<textarea name="soft_tissue" rows="2" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">{{ old('soft_tissue', $dentalVisitNote?->soft_tissue) }}</textarea>
</div>
<div>
<label class="block text-xs font-medium text-slate-600">Occlusion / bite</label>
<textarea name="occlusion" rows="2" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">{{ old('occlusion', $dentalVisitNote?->occlusion) }}</textarea>
</div>
<div>
<label class="block text-xs font-medium text-slate-600">Other notes</label>
<textarea name="notes" rows="3" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">{{ old('notes', $dentalVisitNote?->notes) }}</textarea>
</div>
<button type="submit" class="btn-primary">Save notes</button>
</form>
</section>
@@ -0,0 +1,101 @@
@php
$teethMap = $dentalTeethMap ?? [];
$selected = old('tooth_code', request('tooth', '16'));
$selectedData = $teethMap[$selected] ?? ['status' => 'present', 'surfaces' => [], 'conditions' => [], 'notes' => null];
@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>
</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">
@foreach (['upper' => 'Upper', 'lower' => 'Lower'] as $rowKey => $rowLabel)
<div>
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400">{{ $rowLabel }}</p>
<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']);
@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,
])
title="Tooth {{ $code }}"
>
{{ $code }}
</a>
@endforeach
</div>
</div>
@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"
>
@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">
@foreach ($dentalStatuses as $value => $label)
<option value="{{ $value }}" @selected(($selectedData['status'] ?? 'present') === $value)>{{ $label }}</option>
@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">
@foreach ($dentalSurfaces as $surface)
<label class="inline-flex items-center gap-1.5 text-sm text-slate-700">
<input type="checkbox" name="surfaces[]" value="{{ $surface }}" class="rounded border-slate-300 text-indigo-600"
@checked(in_array($surface, $selectedData['surfaces'] ?? [], true))>
{{ $surface }}
</label>
@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">
@foreach ($dentalConditions as $value => $label)
<label class="inline-flex items-center gap-1.5 text-sm text-slate-700">
<input type="checkbox" name="conditions[]" value="{{ $value }}" class="rounded border-slate-300 text-indigo-600"
@checked(in_array($value, $selectedData['conditions'] ?? [], true))>
{{ $label }}
</label>
@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>
</form>
</section>
@@ -0,0 +1,73 @@
@php
$plan = $dentalPlan;
$openItems = $plan?->items?->whereIn('status', ['proposed', 'accepted']) ?? collect();
@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>
</div>
<a href="{{ route('care.specialty.dentistry.reports') }}" class="text-sm font-medium text-indigo-600">Dentistry reports</a>
</div>
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt class="text-slate-500">Visit status</dt>
<dd class="font-medium text-slate-900">{{ ucfirst(str_replace('_', ' ', $workspaceVisit->status)) }}</dd>
</div>
<div>
<dt class="text-slate-500">Checked in</dt>
<dd class="font-medium text-slate-900">{{ $workspaceVisit->checked_in_at?->format('d M Y H:i') ?? '—' }}</dd>
</div>
<div>
<dt class="text-slate-500">Queue</dt>
<dd class="font-medium text-slate-900">
{{ $workspaceVisit->appointment?->queue_ticket_number ?? '—' }}
@if ($workspaceVisit->appointment?->queue_ticket_status)
<span class="text-slate-500">({{ $workspaceVisit->appointment->queue_ticket_status }})</span>
@endif
</dd>
</div>
<div>
<dt class="text-slate-500">Treatment plan</dt>
<dd class="font-medium text-slate-900">
@if ($plan)
{{ ucfirst(str_replace('_', ' ', $plan->status)) }}
· {{ $currency ?? 'GHS' }} {{ number_format($plan->estimate_minor / 100, 2) }}
@else
None open
@endif
</dd>
</div>
</dl>
@if ($openItems->isNotEmpty())
<div class="mt-4 border-t border-slate-100 pt-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Unfinished plan items</p>
<ul class="mt-2 space-y-1 text-sm">
@foreach ($openItems->take(8) as $item)
<li class="flex justify-between gap-3">
<span>
{{ $item->label }}
@if ($item->tooth_code) <span class="text-slate-400">· {{ $item->tooth_code }}</span> @endif
</span>
<span class="tabular-nums text-slate-500">{{ number_format($item->estimate_minor / 100, 2) }}</span>
</li>
@endforeach
</ul>
</div>
@endif
@if (! empty($clinicalAlerts))
<div class="mt-4 space-y-1.5 border-t border-slate-100 pt-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Alerts</p>
@foreach ($clinicalAlerts as $alert)
<p class="text-sm {{ ($alert['severity'] ?? '') === 'critical' ? 'text-rose-700' : 'text-amber-800' }}">
{{ $alert['message'] ?? '' }}
</p>
@endforeach
</div>
@endif
</section>
@@ -0,0 +1,92 @@
@php
$plan = $dentalPlan;
@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">Treatment plan</h3>
<p class="mt-0.5 text-xs text-slate-500">
@if ($plan)
Status: <span class="font-medium">{{ ucfirst(str_replace('_', ' ', $plan->status)) }}</span>
· Estimate {{ $currency ?? 'GHS' }} {{ number_format($plan->estimate_minor / 100, 2) }}
@else
Create a plan by adding the first item.
@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>
@endif
</div>
<ul class="mt-4 space-y-2 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>
</div>
<span class="tabular-nums text-slate-600">{{ number_format($item->estimate_minor / 100, 2) }}</span>
</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
</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>
</section>
@@ -0,0 +1,97 @@
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Treat today</h3>
<p class="mt-0.5 text-xs text-slate-500">Record a completed procedure. It posts to the visit bill automatically.</p>
@if ($dentalPlan?->items)
<div class="mt-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">From plan</p>
<ul class="mt-2 space-y-2">
@foreach ($dentalPlan->items->whereIn('status', ['proposed', 'accepted']) as $item)
<li class="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<form method="POST" action="{{ route('care.specialty.dentistry.procedures', $workspaceVisit) }}" class="flex flex-wrap items-center justify-between gap-2">
@csrf
<input type="hidden" name="procedure_code" value="{{ $item->procedure_code }}">
<input type="hidden" name="tooth_code" value="{{ $item->tooth_code }}">
<input type="hidden" name="plan_item_id" value="{{ $item->id }}">
<input type="hidden" name="bill" value="1">
@foreach ($item->surfaces ?? [] as $surface)
<input type="hidden" name="surfaces[]" value="{{ $surface }}">
@endforeach
<div class="text-sm">
<p class="font-medium text-slate-800">{{ $item->label }} @if($item->tooth_code)<span class="text-slate-500">· {{ $item->tooth_code }}</span>@endif</p>
<p class="text-xs text-slate-500">{{ number_format($item->estimate_minor / 100, 2) }}</p>
</div>
<button type="submit" class="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700">Complete &amp; bill</button>
</form>
</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="{{ route('care.specialty.dentistry.procedures', $workspaceVisit) }}" class="mt-6 space-y-3 border-t border-slate-100 pt-4">
@csrf
<h4 class="text-sm font-semibold text-slate-900">Ad-hoc procedure</h4>
<input type="hidden" name="bill" value="1">
<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'] }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-600">Tooth</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">Anesthesia</label>
<select name="anesthesia" class="mt-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm">
<option value=""></option>
<option value="None">None</option>
<option value="Local">Local</option>
<option value="Sedation">Sedation</option>
<option value="GA">GA</option>
</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">
@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>
<button type="submit" class="btn-primary">Complete &amp; bill</button>
</form>
<div class="mt-6 border-t border-slate-100 pt-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Completed this visit</p>
<ul class="mt-2 space-y-1 text-sm">
@forelse ($dentalProcedures as $proc)
<li class="flex justify-between gap-3">
<span>{{ $proc->label }} · {{ $proc->performed_at?->format('H:i') }}</span>
<span class="tabular-nums">{{ number_format($proc->amount_minor / 100, 2) }}</span>
</li>
@empty
<li class="text-slate-500">No procedures yet.</li>
@endforelse
</ul>
</div>
</section>
@@ -22,8 +22,8 @@
&& $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED;
$chartTab = collect($workspaceTabs ?? [])
->keys()
->first(fn ($key) => ! in_array($key, ['overview', 'timeline', 'orders', 'billing', 'documents'], true))
?: 'clinical_notes';
->first(fn ($key) => ! in_array($key, ['overview', 'timeline', 'plan', 'treat', 'notes', 'imaging', 'orders', 'billing', 'documents'], true))
?: 'odontogram';
@endphp
@if ($onWorkspace)
@@ -36,6 +36,8 @@
<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))
@include('care.specialty.dentistry.workspace-'.$activeTab)
@elseif ($activeTab === 'billing')
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Add specialty service</h3>
@@ -13,6 +13,9 @@
<div class="flex flex-wrap items-center justify-between gap-3">
<a href="{{ route('care.queue.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Back to queue</a>
<div class="flex flex-wrap gap-3 text-sm">
@if ($moduleKey === 'dentistry')
<a href="{{ route('care.specialty.dentistry.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
@endif
<a href="{{ route('care.specialty.history', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">History</a>
<a href="{{ route('care.specialty.billing', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">Billing</a>
</div>
+9
View File
@@ -34,6 +34,7 @@ use App\Http\Controllers\Care\ReportController;
use App\Http\Controllers\Care\ServiceQueueController;
use App\Http\Controllers\Care\SettingsController;
use App\Http\Controllers\Care\SettingsModulesController;
use App\Http\Controllers\Care\DentistryWorkspaceController;
use App\Http\Controllers\Care\SpecialtyModuleController;
use App\Http\Controllers\Care\VisitWorkflowController;
use App\Http\Controllers\NotificationController;
@@ -111,11 +112,19 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/specialty/{module}/visits', [SpecialtyModuleController::class, 'visits'])->name('care.specialty.visits');
Route::get('/specialty/{module}/history', [SpecialtyModuleController::class, 'history'])->name('care.specialty.history');
Route::get('/specialty/{module}/billing', [SpecialtyModuleController::class, 'billing'])->name('care.specialty.billing');
Route::get('/specialty/dentistry/reports', [DentistryWorkspaceController::class, 'reports'])->name('care.specialty.dentistry.reports');
Route::get('/specialty/{module}/workspace/{visit?}', [SpecialtyModuleController::class, 'workspace'])->name('care.specialty.workspace');
Route::post('/specialty/{module}/workspace/{visit}/clinical', [SpecialtyModuleController::class, 'saveClinical'])->name('care.specialty.clinical.save');
Route::post('/specialty/{module}/workspace/{visit}/documents', [SpecialtyModuleController::class, 'uploadDocument'])->name('care.specialty.documents.upload');
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}/plan-items', [DentistryWorkspaceController::class, 'addPlanItem'])->name('care.specialty.dentistry.plan-items');
Route::post('/specialty/dentistry/workspace/{visit}/plan/accept', [DentistryWorkspaceController::class, 'acceptPlan'])->name('care.specialty.dentistry.plan.accept');
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::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');
+269
View File
@@ -0,0 +1,269 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\BillLineItem;
use App\Models\Branch;
use App\Models\DentalChart;
use App\Models\DentalChartEvent;
use App\Models\DentalPlanItem;
use App\Models\DentalProcedure;
use App\Models\DentalTreatmentPlan;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\User;
use App\Models\Visit;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class CareDentistrySuiteTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected Visit $visit;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->owner = User::create([
'public_id' => 'den-owner',
'name' => 'Owner',
'email' => 'den-owner@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->owner->public_id,
'name' => 'Dental Clinic',
'slug' => 'dental-clinic',
'settings' => [
'onboarded' => true,
'facility_type' => 'clinic',
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'queue_integration_enabled' => true,
],
]);
Member::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->owner->public_id,
'role' => 'hospital_admin',
'branch_id' => null,
]);
$this->branch = Branch::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
app(SpecialtyModuleService::class)->activate(
$this->organization,
$this->owner->public_id,
'dentistry',
);
$department = Department::query()
->where('branch_id', $this->branch->id)
->where('type', 'dental')
->firstOrFail();
$this->patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-DEN-SUITE',
'first_name' => 'Afia',
'last_name' => 'Darko',
'gender' => 'female',
'date_of_birth' => '1992-01-01',
]);
$this->visit = Visit::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'status' => Visit::STATUS_IN_PROGRESS,
'checked_in_at' => now(),
]);
Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'department_id' => $department->id,
'visit_id' => $this->visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_IN_CONSULTATION,
'scheduled_at' => now(),
'waiting_at' => now(),
'checked_in_at' => now(),
'started_at' => now(),
]);
}
public function test_chart_tab_renders_odontogram_not_text_form(): void
{
$this->actingAs($this->owner)
->get(route('care.specialty.workspace', [
'module' => 'dentistry',
'visit' => $this->visit,
'tab' => 'odontogram',
]))
->assertOk()
->assertSee('Odontogram (FDI)')
->assertSee('18')
->assertSee('Save tooth')
->assertDontSee('Teeth affected (FDI numbers');
}
public function test_updating_tooth_persists_chart_and_event(): void
{
$this->actingAs($this->owner)
->post(route('care.specialty.dentistry.tooth', $this->visit), [
'tooth_code' => '16',
'status' => 'present',
'surfaces' => ['O', 'M'],
'conditions' => ['caries'],
'notes' => 'Occlusal caries',
])
->assertRedirect(route('care.specialty.workspace', [
'module' => 'dentistry',
'visit' => $this->visit,
'tab' => 'odontogram',
]));
$chart = DentalChart::query()->where('patient_id', $this->patient->id)->first();
$this->assertNotNull($chart);
$tooth = $chart->teeth()->where('tooth_code', '16')->first();
$this->assertSame(['O', 'M'], $tooth->surfaces);
$this->assertSame(['caries'], $tooth->conditions);
$this->assertDatabaseHas('care_dental_chart_events', [
'dental_chart_id' => $chart->id,
'tooth_code' => '16',
'event_type' => 'tooth_updated',
]);
$this->assertSame(1, DentalChartEvent::query()->where('dental_chart_id', $chart->id)->count());
}
public function test_plan_procedure_and_bill_flow(): void
{
$this->actingAs($this->owner)
->post(route('care.specialty.dentistry.plan-items', $this->visit), [
'procedure_code' => 'den.fill',
'tooth_code' => '16',
'surfaces' => ['O'],
'priority' => 10,
])
->assertRedirect();
$plan = DentalTreatmentPlan::query()->where('patient_id', $this->patient->id)->first();
$this->assertNotNull($plan);
$item = $plan->items()->first();
$this->assertSame('den.fill', $item->procedure_code);
$this->actingAs($this->owner)
->post(route('care.specialty.dentistry.plan.accept', $this->visit))
->assertRedirect();
$this->assertSame(DentalTreatmentPlan::STATUS_ACCEPTED, $plan->fresh()->status);
$this->actingAs($this->owner)
->post(route('care.specialty.dentistry.procedures', $this->visit), [
'procedure_code' => 'den.fill',
'tooth_code' => '16',
'surfaces' => ['O'],
'plan_item_id' => $item->id,
'bill' => 1,
'anesthesia' => 'Local',
])
->assertRedirect(route('care.specialty.workspace', [
'module' => 'dentistry',
'visit' => $this->visit,
'tab' => 'treat',
]));
$procedure = DentalProcedure::query()->where('visit_id', $this->visit->id)->first();
$this->assertNotNull($procedure);
$this->assertNotNull($procedure->bill_line_item_id);
$this->assertSame(DentalPlanItem::STATUS_DONE, $item->fresh()->status);
$this->assertDatabaseHas('care_bill_line_items', [
'id' => $procedure->bill_line_item_id,
'source_type' => 'dental_procedure',
'source_id' => $procedure->id,
]);
}
public function test_imaging_upload_and_reports_page(): void
{
Storage::fake('public');
$this->actingAs($this->owner)
->post(route('care.specialty.dentistry.images', $this->visit), [
'attachment' => UploadedFile::fake()->image('xray.jpg'),
'modality' => 'pa',
'tooth_codes' => ['16'],
'caption' => 'PA 16',
'add_fee' => 0,
])
->assertRedirect(route('care.specialty.workspace', [
'module' => 'dentistry',
'visit' => $this->visit,
'tab' => 'imaging',
]));
$this->assertDatabaseHas('care_dental_images', [
'visit_id' => $this->visit->id,
'modality' => 'pa',
]);
$this->actingAs($this->owner)
->get(route('care.specialty.dentistry.reports'))
->assertOk()
->assertSee('Dentistry reports');
}
public function test_notes_and_print_views(): void
{
$this->actingAs($this->owner)
->post(route('care.specialty.dentistry.notes', $this->visit), [
'chief_complaint' => 'Toothache upper right',
'soft_tissue' => 'WNL',
'occlusion' => 'Class I',
'notes' => 'Demo note',
])
->assertRedirect();
$this->assertDatabaseHas('care_dental_visit_notes', [
'visit_id' => $this->visit->id,
'chief_complaint' => 'Toothache upper right',
]);
$this->actingAs($this->owner)
->get(route('care.specialty.dentistry.print', $this->visit))
->assertOk()
->assertSee('Odontogram (FDI)')
->assertSee('Afia Darko');
}
}