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