Files
ladill-care/app/Http/Controllers/Care/AppointmentController.php
T
isaaccladandCursor 2ac84faf73
Deploy Ladill Care / deploy (push) Successful in 27s
Label multi-branch department picks and add searchable patients.
Make appointment/practitioner department (and practitioner) options show branch names so duplicates are distinguishable, and replace the patient select with a searchable dropdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:31:32 +00:00

342 lines
13 KiB
PHP

<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Practitioner;
use App\Services\Care\AdminOverviewService;
use App\Services\Care\AppointmentService;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\Workflow\WorkflowEngine;
use App\Services\Care\Workflow\WorkflowQueueGate;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AppointmentController extends Controller
{
use ScopesToAccount;
public function __construct(
protected AppointmentService $appointments,
protected AdminOverviewService $adminOverview,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'appointments.view');
$organization = $this->organization($request);
$member = $this->member($request);
$resolver = app(OrganizationResolver::class);
$branchScope = $resolver->branchScope($member);
$practitionerScope = $resolver->practitionerScope($organization, $member);
$permissions = app(CarePermissions::class);
if ($permissions->isAdmin($member) && $request->query('view') !== 'list') {
return view('care.admin.analytics', [
'title' => 'Appointments',
'badge' => 'Analytics · Throughput',
'description' => 'Scheduling, waiting, and completion rates across the facility.',
'adminOverview' => $this->adminOverview->appointments(
$organization,
$this->ownerRef($request),
$branchScope,
),
]);
}
$filters = $request->only(['status', 'practitioner_id', 'date', 'patient_id']);
if ($practitionerScope !== null) {
unset($filters['practitioner_id']);
}
$appointments = $this->appointments->list(
$this->ownerRef($request),
$organization->id,
$filters,
$practitionerScope === null ? $branchScope : null,
$practitionerScope,
);
$practitioners = $practitionerScope === null
? $this->activePractitioners($request, $organization->id)
: collect();
$owner = $this->ownerRef($request);
$appointmentQuery = Appointment::owned($owner)
->where('organization_id', $organization->id);
if ($practitionerScope !== null) {
$appointmentQuery->whereIn('practitioner_id', $practitionerScope ?: [0]);
} elseif ($branchScope) {
$appointmentQuery->where('branch_id', $branchScope);
}
$heroStats = [
'today' => (clone $appointmentQuery)->whereDate('scheduled_at', today())->count(),
'scheduled' => (clone $appointmentQuery)->where('status', Appointment::STATUS_SCHEDULED)->count(),
'waiting' => (clone $appointmentQuery)->whereIn('status', [
Appointment::STATUS_CHECKED_IN,
Appointment::STATUS_WAITING,
])->count(),
];
return view('care.appointments.index', [
'organization' => $organization,
'appointments' => $appointments,
'practitioners' => $practitioners,
'lockToPractitioner' => $practitionerScope !== null,
'statuses' => config('care.appointment_statuses'),
'heroStats' => $heroStats,
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'appointments.manage');
$organization = $this->organization($request);
return view('care.appointments.create', $this->formData($request, $organization));
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$organization = $this->organization($request);
$validated = $this->validatedAppointmentData($request);
$appointment = $this->appointments->book(
$organization,
$this->ownerRef($request),
$validated,
$this->ownerRef($request),
);
return redirect()->route('care.appointments.show', $appointment)
->with('success', 'Appointment booked.');
}
public function walkInCreate(Request $request): View
{
$this->authorizeAbility($request, 'appointments.manage');
$organization = $this->organization($request);
return view('care.appointments.walk-in', $this->formData($request, $organization));
}
public function walkInStore(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$organization = $this->organization($request);
$validated = $this->validatedWalkInData($request);
$appointment = $this->appointments->walkIn(
$organization,
$this->ownerRef($request),
$validated,
$this->ownerRef($request),
);
if (app(CareFeatures::class)->enabled($organization, CareFeatures::WORKFLOW_ENGINE)) {
return redirect()->route('care.appointments.show', $appointment)
->with('success', 'Walk-in checked in. Complete the current patient journey stage before Queue release.');
}
return redirect()->route('care.queue.index')
->with('success', 'Walk-in added to queue.');
}
public function show(Request $request, Appointment $appointment): View
{
$this->authorizeAbility($request, 'appointments.view');
$this->authorizeAppointment($request, $appointment);
$appointment->load(['patient', 'practitioner', 'branch', 'department', 'visit', 'consultation']);
$canManage = app(CarePermissions::class)
->can($this->member($request), 'appointments.manage');
$canConsult = app(CarePermissions::class)
->can($this->member($request), 'consultations.manage');
$canViewBills = app(CarePermissions::class)
->can($this->member($request), 'bills.view');
$organization = $this->organization($request);
$workflowEnabled = $appointment->visit
&& app(CareFeatures::class)->enabled($organization, CareFeatures::WORKFLOW_ENGINE);
$workflowEngine = app(WorkflowEngine::class);
$currentAdvance = $workflowEnabled
? $workflowEngine->refreshGate($appointment->visit)
: null;
$currentObligation = $currentAdvance?->stage
? $appointment->visit->financialObligations()
->where('workflow_stage_id', $currentAdvance->stage->id)
->latest('id')
->first()
: null;
$canStartConsultation = $canConsult && (! $workflowEnabled || app(WorkflowQueueGate::class)->canRelease(
$organization,
$appointment->visit,
$this->appointments->queueContextFor($organization, $appointment),
));
return view('care.appointments.show', [
'appointment' => $appointment,
'statuses' => config('care.appointment_statuses'),
'canManage' => $canManage,
'canConsult' => $canConsult,
'canViewBills' => $canViewBills,
'canStartConsultation' => $canStartConsultation,
'workflowEnabled' => $workflowEnabled,
'currentAdvance' => $currentAdvance,
'currentObligation' => $currentObligation,
'workflowHistory' => $workflowEnabled
? $appointment->visit->stageAdvances()->with('stage')->orderBy('id')->get()
: collect(),
'canAdvanceWorkflow' => $canManage
&& $workflowEnabled
&& $workflowEngine->canManuallyAdvance($appointment->visit),
]);
}
public function checkIn(Request $request, Appointment $appointment): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeAppointment($request, $appointment);
$appointment = $this->appointments->checkIn($appointment, $this->ownerRef($request), $this->ownerRef($request));
if (app(CareFeatures::class)->enabled($this->organization($request), CareFeatures::WORKFLOW_ENGINE)) {
return redirect()->route('care.appointments.show', $appointment)
->with('success', 'Patient checked in. Complete the current patient journey stage before Queue release.');
}
return redirect()->route('care.queue.index')
->with('success', 'Patient checked in and added to queue.');
}
public function cancel(Request $request, Appointment $appointment): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeAppointment($request, $appointment);
$this->appointments->cancel($appointment, $this->ownerRef($request), $this->ownerRef($request));
return back()->with('success', 'Appointment cancelled.');
}
public function noShow(Request $request, Appointment $appointment): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeAppointment($request, $appointment);
$this->appointments->markNoShow($appointment, $this->ownerRef($request), $this->ownerRef($request));
return back()->with('success', 'Marked as no-show.');
}
protected function authorizeAppointment(Request $request, Appointment $appointment): void
{
$this->authorizeOwner($request, $appointment);
abort_unless($appointment->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $appointment->branch_id !== $branchId) {
abort(404);
}
}
/**
* @return array<string, mixed>
*/
protected function formData(Request $request, Organization $organization): array
{
$ownerRef = $this->ownerRef($request);
$branchQuery = Branch::owned($ownerRef)->where('organization_id', $organization->id)->where('is_active', true);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchScope !== null) {
$branchQuery->where('id', $branchScope);
}
$branches = $branchQuery->orderBy('name')->get();
$defaultBranch = $branchScope ?? $branches->first()?->id;
$patients = Patient::owned($ownerRef)
->where('organization_id', $organization->id)
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->orderBy('first_name')
->limit(200)
->get();
$departments = Department::owned($ownerRef)
->with('branch')
->whereIn('branch_id', $branches->pluck('id'))
->where('is_active', true)
->get()
->sortBy(fn (Department $d) => strtolower(($d->branch?->name ?? '').' '.$d->name))
->values();
return [
'organization' => $organization,
'branches' => $branches,
'defaultBranch' => $defaultBranch,
'patients' => $patients,
'practitioners' => $this->activePractitioners($request, $organization->id),
'departments' => $departments,
];
}
/**
* @return Collection<int, Practitioner>
*/
protected function activePractitioners(Request $request, int $organizationId)
{
return Practitioner::owned($this->ownerRef($request))
->with(['branch', 'branches'])
->where('organization_id', $organizationId)
->where('is_active', true)
->orderBy('name')
->get();
}
/**
* @return array<string, mixed>
*/
protected function validatedAppointmentData(Request $request): array
{
return $request->validate([
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
'department_id' => ['nullable', 'integer', 'exists:care_departments,id'],
'scheduled_at' => ['required', 'date', 'after:now'],
'reason' => ['nullable', 'string', 'max:1000'],
'notes' => ['nullable', 'string', 'max:5000'],
]);
}
/**
* @return array<string, mixed>
*/
protected function validatedWalkInData(Request $request): array
{
$queueOn = (bool) data_get($this->organization($request)->settings, 'queue_integration_enabled');
return $request->validate([
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
'practitioner_id' => [$queueOn ? 'required' : 'nullable', 'integer', 'exists:care_practitioners,id'],
'department_id' => ['nullable', 'integer', 'exists:care_departments,id'],
'reason' => ['nullable', 'string', 'max:1000'],
'notes' => ['nullable', 'string', 'max:5000'],
]);
}
}