authorizeAbility($request, 'patients.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' => 'Patients', 'badge' => 'Analytics · Population', 'description' => 'Registration and visit trends across your facility — not a bedside patient list.', 'adminOverview' => $this->adminOverview->patients( $organization, $this->ownerRef($request), $branchScope, ), ]); } $patients = $this->patients->search( $this->ownerRef($request), $organization->id, $request->only(['q', 'patient_number', 'phone', 'national_id', 'date_of_birth']), $practitionerScope === null ? $branchScope : null, $practitionerScope, ); $owner = $this->ownerRef($request); $patientQuery = Patient::owned($owner) ->where('organization_id', $organization->id); if ($practitionerScope !== null) { if ($practitionerScope === []) { $patientQuery->whereRaw('1 = 0'); } else { $patientQuery->whereHas( 'appointments', fn ($q) => $q->whereIn('practitioner_id', $practitionerScope), ); } } elseif ($branchScope) { $patientQuery->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(); } $isNurseStation = $permissions->can($member, 'nursing.station.view'); $canConsult = $permissions->can($member, 'consultations.manage'); $canViewLab = $permissions->can($member, 'lab.view') || $permissions->can($member, 'lab.results.view'); $canViewRx = $permissions->can($member, 'prescriptions.view') || $permissions->can($member, 'prescriptions.manage'); $canViewBills = $permissions->can($member, 'bills.view'); $canViewMar = $permissions->can($member, 'mar.view') || $permissions->can($member, 'mar.manage'); $canViewAssessments = $assessmentsEnabled && $permissions->can($member, 'assessments.view'); // GP instruments / pathways CTAs stay consult-gated; nurses capture via unit boards. $canCaptureAssessment = $assessmentsEnabled && $canConsult && ( $permissions->can($member, 'assessments.capture') || $permissions->can($member, 'assessments.manage') ); $canViewPathways = $canConsult && $permissions->can($member, 'pathways.manage'); $recentAssessments = collect(); $latestUniversalIntake = null; $nursingAssessments = collect(); $activePlacedVisit = null; $latestVitals = null; $nurseDutyUnits = collect(); if ($canViewAssessments) { $branchScope = app(OrganizationResolver::class)->branchScope($member); $assessmentQuery = $patient->assessments() ->with('template') ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) ->orderByDesc('created_at'); if ($isNurseStation && ! $canConsult) { $nursingAssessments = (clone $assessmentQuery) ->whereHas('template', fn ($q) => $q->whereIn( 'code', \App\Services\Care\NursingAssessmentService::NURSING_PACK_CODES, )) ->limit(10) ->get(); } else { $recentAssessments = (clone $assessmentQuery)->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(); } } if ($isNurseStation) { $nurseDutyUnits = app(\App\Services\Care\NurseStationService::class) ->dutyUnits($organization, $member, $this->ownerRef($request)); $activePlacedVisit = $patient->visits() ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) ->whereNotNull('care_unit_id') ->with(['careUnit.department.branch', 'bed']) ->orderByDesc('placed_at') ->first(); if ($activePlacedVisit) { $latestVitals = \App\Models\VisitVital::owned($this->ownerRef($request)) ->where('visit_id', $activePlacedVisit->id) ->orderByDesc('recorded_at') ->first(); } } return view('care.patients.show', array_merge($dashboard, [ 'canManage' => $canManage, 'isNurseStation' => $isNurseStation, 'canConsult' => $canConsult, 'canViewLab' => $canViewLab, 'canViewRx' => $canViewRx, 'canViewBills' => $canViewBills, 'canViewMar' => $canViewMar, 'canViewPathways' => $canViewPathways, 'activePlacedVisit' => $activePlacedVisit, 'latestVitals' => $latestVitals, 'nurseDutyUnits' => $nurseDutyUnits, 'nursingAssessments' => $nursingAssessments, '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'), 'patientHeaderMeta' => app(\App\Services\Care\SpecialtyShellService::class)->patientHeaderMeta($patient), 'patientTimeline' => app(\App\Services\Care\SpecialtyShellService::class)->patientTimeline($patient), 'currency' => strtoupper((string) config('care.billing.currency', 'GHS')), ])); } 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) { return; } // Home branch may differ from the site where care is delivered — allow chart // access when the patient has a visit or appointment at the member's branch. $hasLocalActivity = Visit::query() ->where('patient_id', $patient->id) ->where('organization_id', $patient->organization_id) ->where('branch_id', $branchId) ->exists() || Appointment::query() ->where('patient_id', $patient->id) ->where('organization_id', $patient->organization_id) ->where('branch_id', $branchId) ->exists(); abort_unless($hasLocalActivity, 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'], ]); } }