authorizeAbility($request, 'patients.view'); $organization = $this->organization($request); $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); $patients = $this->patients->search( $this->ownerRef($request), $organization->id, $request->only(['q', 'patient_number', 'phone', 'national_id', 'date_of_birth']), $branchScope, ); $owner = $this->ownerRef($request); $patientQuery = Patient::owned($owner) ->where('organization_id', $organization->id) ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)); $heroStats = [ 'total' => (clone $patientQuery)->count(), 'new_this_month' => (clone $patientQuery)->where('created_at', '>=', now()->startOfMonth())->count(), 'with_visits' => (clone $patientQuery)->whereHas('appointments')->count(), ]; return view('care.patients.index', compact('patients', 'organization', 'heroStats')); } public function scanLookup(Request $request): JsonResponse|RedirectResponse { $this->authorizeAbility($request, 'patients.view'); $organization = $this->organization($request); $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); $code = trim((string) $request->query('code', '')); abort_unless($code !== '', 422, 'Scan code required.'); $patient = $this->patients->findByScanCode( $this->ownerRef($request), $organization->id, $code, $branchScope, ); if ($request->expectsJson() || $request->wantsJson() || $request->ajax()) { if (! $patient) { return response()->json(['message' => 'No patient found for that barcode.'], 404); } return response()->json([ 'patient' => [ 'uuid' => $patient->uuid, 'patient_number' => $patient->patient_number, 'name' => $patient->fullName(), 'url' => route('care.patients.show', $patient), ], ]); } if (! $patient) { return redirect() ->route('care.patients.index', ['q' => $code]) ->with('error', 'No patient found for that barcode.'); } return redirect()->route('care.patients.show', $patient); } public function label(Request $request, Patient $patient): View { $this->authorizeAbility($request, 'patients.view'); $this->authorizePatient($request, $patient); return view('care.patients.label', [ 'patient' => $patient, 'organization' => $this->organization($request), 'genders' => config('care.genders'), ]); } public function create(Request $request): View { $this->authorizeAbility($request, 'patients.manage'); $organization = $this->organization($request); $branches = Branch::owned($this->ownerRef($request)) ->where('organization_id', $organization->id) ->where('is_active', true) ->orderBy('name') ->get(); return view('care.patients.create', [ 'organization' => $organization, 'branches' => $branches, 'genders' => config('care.genders'), 'allergySeverities' => config('care.allergy_severities'), 'documentTypes' => config('care.document_types'), ]); } public function store(Request $request): RedirectResponse { $this->authorizeAbility($request, 'patients.manage'); $organization = $this->organization($request); $validated = $this->validatedPatientData($request); $patient = $this->patients->create( $organization, $this->ownerRef($request), $validated, $this->ownerRef($request), ); return redirect()->route('care.patients.show', $patient) ->with('success', 'Patient registered successfully.'); } public function show(Request $request, Patient $patient): View { $this->authorizeAbility($request, 'patients.view'); $this->authorizePatient($request, $patient); $dashboard = $this->patients->dashboard($patient); $permissions = app(CarePermissions::class); $member = $this->member($request); $organization = $this->organization($request); $canManage = $permissions->can($member, 'patients.manage'); $credential = app(\App\Services\Messaging\MessagingCredentialsService::class) ->forOrganization($organization); $suiteEmailReady = app(\App\Services\Billing\PlatformEmailClient::class)->isConfigured(); $suiteSmsReady = app(\App\Services\Billing\PlatformSmsClient::class)->isConfigured(); $careFeatures = app(CareFeatures::class); $assessmentsEnabled = $careFeatures->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE); if ($assessmentsEnabled) { $careFeatures->ensureAssessmentCatalog(); } $canViewAssessments = $assessmentsEnabled && $permissions->can($member, 'assessments.view'); $canCaptureAssessment = $assessmentsEnabled && ( $permissions->can($member, 'assessments.capture') || $permissions->can($member, 'assessments.manage') ); $recentAssessments = collect(); $latestUniversalIntake = null; if ($canViewAssessments) { $branchScope = app(OrganizationResolver::class)->branchScope($member); $recentAssessments = $patient->assessments() ->with('template') ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) ->orderByDesc('created_at') ->limit(10) ->get(); $latestUniversalIntake = $patient->assessments() ->with(['template', 'answers.question']) ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) ->whereHas('template', fn ($q) => $q->where('code', 'universal_intake')) ->whereIn('status', [\App\Models\Assessment::STATUS_DRAFT, \App\Models\Assessment::STATUS_COMPLETED]) ->orderByDesc('created_at') ->first(); } return view('care.patients.show', array_merge($dashboard, [ 'canManage' => $canManage, 'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(), 'messagingEmailReady' => $suiteEmailReady || $credential->hasValidBird(), 'suiteMessagingReady' => $suiteEmailReady || $suiteSmsReady, 'messagingSettingsUrl' => function_exists('ladill_account_url') ? ladill_account_url('/account/settings/messaging') : (string) config('smtp.messaging_settings_url', 'https://account.ladill.com/account/settings/messaging'), 'genders' => config('care.genders'), 'allergySeverities' => config('care.allergy_severities'), 'documentTypes' => config('care.document_types'), 'assessmentsEnabled' => $assessmentsEnabled, 'canViewAssessments' => $canViewAssessments, 'canCaptureAssessment' => $canCaptureAssessment, 'recentAssessments' => $recentAssessments, 'latestUniversalIntake' => $latestUniversalIntake, 'assessmentStatuses' => config('care.assessment_statuses'), ])); } public function edit(Request $request, Patient $patient): View { $this->authorizeAbility($request, 'patients.manage'); $this->authorizePatient($request, $patient); $organization = $this->organization($request); $patient->load(['allergies', 'conditions', 'familyHistory', 'emergencyContacts', 'insurancePolicies', 'attachments']); $branches = Branch::owned($this->ownerRef($request)) ->where('organization_id', $organization->id) ->where('is_active', true) ->orderBy('name') ->get(); return view('care.patients.edit', [ 'patient' => $patient, 'organization' => $organization, 'branches' => $branches, 'genders' => config('care.genders'), 'allergySeverities' => config('care.allergy_severities'), 'documentTypes' => config('care.document_types'), ]); } public function update(Request $request, Patient $patient): RedirectResponse { $this->authorizeAbility($request, 'patients.manage'); $this->authorizePatient($request, $patient); $validated = $this->validatedPatientData($request); $this->patients->update($patient, $this->ownerRef($request), $validated, $this->ownerRef($request)); return redirect()->route('care.patients.show', $patient) ->with('success', 'Patient record updated.'); } public function destroy(Request $request, Patient $patient): RedirectResponse { $this->authorizeAbility($request, 'patients.manage'); $this->authorizePatient($request, $patient); $this->patients->delete($patient, $this->ownerRef($request), $this->ownerRef($request)); return redirect()->route('care.patients.index') ->with('success', 'Patient record archived.'); } protected function authorizePatient(Request $request, Patient $patient): void { $this->authorizeOwner($request, $patient); abort_unless($patient->organization_id === $this->organization($request)->id, 404); $branchId = app(OrganizationResolver::class)->branchScope($this->member($request)); if ($branchId !== null && $patient->branch_id !== $branchId) { abort(404); } } /** * @return array */ protected function validatedPatientData(Request $request): array { return $request->validate([ 'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'], 'first_name' => ['required', 'string', 'max:100'], 'last_name' => ['required', 'string', 'max:100'], 'other_names' => ['nullable', 'string', 'max:100'], 'gender' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.genders')))], 'date_of_birth' => ['nullable', 'date', 'before:today'], 'phone' => ['nullable', 'string', 'max:30'], 'email' => ['nullable', 'email', 'max:255'], 'national_id' => ['nullable', 'string', 'max:50'], 'address' => ['nullable', 'string', 'max:500'], 'city' => ['nullable', 'string', 'max:100'], 'region' => ['nullable', 'string', 'max:100'], 'notes' => ['nullable', 'string', 'max:5000'], 'allergies' => ['nullable', 'array'], 'allergies.*.allergen' => ['nullable', 'string', 'max:255'], 'allergies.*.severity' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.allergy_severities')))], 'allergies.*.notes' => ['nullable', 'string', 'max:1000'], 'conditions' => ['nullable', 'array'], 'conditions.*.condition' => ['nullable', 'string', 'max:255'], 'conditions.*.onset_date' => ['nullable', 'date'], 'conditions.*.is_chronic' => ['nullable', 'boolean'], 'conditions.*.notes' => ['nullable', 'string', 'max:1000'], 'family_history' => ['nullable', 'array'], 'family_history.*.relation' => ['nullable', 'string', 'max:100'], 'family_history.*.condition' => ['nullable', 'string', 'max:255'], 'family_history.*.notes' => ['nullable', 'string', 'max:1000'], 'emergency_contacts' => ['nullable', 'array'], 'emergency_contacts.*.name' => ['nullable', 'string', 'max:255'], 'emergency_contacts.*.phone' => ['nullable', 'string', 'max:30'], 'emergency_contacts.*.relationship' => ['nullable', 'string', 'max:100'], 'emergency_contacts.*.is_primary' => ['nullable', 'boolean'], 'insurance' => ['nullable', 'array'], 'insurance.*.provider_name' => ['nullable', 'string', 'max:255'], 'insurance.*.policy_number' => ['nullable', 'string', 'max:100'], 'insurance.*.coverage_type' => ['nullable', 'string', 'max:100'], 'insurance.*.expiry_date' => ['nullable', 'date'], 'insurance.*.notes' => ['nullable', 'string', 'max:1000'], 'attachments' => ['nullable', 'array'], 'attachments.*' => ['file', 'max:10240', 'mimes:pdf,jpeg,png,jpg,webp'], ]); } }