From 2ac84faf73cf933a5437a1997cd1560fbe09c490 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 18 Jul 2026 17:31:32 +0000 Subject: [PATCH] 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 --- .../Care/AppointmentController.php | 7 +- .../Care/PractitionerController.php | 6 +- app/Models/Department.php | 16 +++ app/Models/Practitioner.php | 24 ++++ resources/js/app.js | 43 +++++++ .../care/admin/practitioners/create.blade.php | 2 +- .../care/admin/practitioners/edit.blade.php | 2 +- .../views/care/appointments/_form.blade.php | 23 ++-- .../components/searchable-select.blade.php | 90 +++++++++++++++ tests/Feature/CareSelectLabelsTest.php | 105 ++++++++++++++++++ 10 files changed, 304 insertions(+), 14 deletions(-) create mode 100644 resources/views/components/searchable-select.blade.php create mode 100644 tests/Feature/CareSelectLabelsTest.php diff --git a/app/Http/Controllers/Care/AppointmentController.php b/app/Http/Controllers/Care/AppointmentController.php index 7fc8098..95e4e09 100644 --- a/app/Http/Controllers/Care/AppointmentController.php +++ b/app/Http/Controllers/Care/AppointmentController.php @@ -276,10 +276,12 @@ class AppointmentController extends Controller ->get(); $departments = Department::owned($ownerRef) + ->with('branch') ->whereIn('branch_id', $branches->pluck('id')) ->where('is_active', true) - ->orderBy('name') - ->get(); + ->get() + ->sortBy(fn (Department $d) => strtolower(($d->branch?->name ?? '').' '.$d->name)) + ->values(); return [ 'organization' => $organization, @@ -297,6 +299,7 @@ class AppointmentController extends Controller 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') diff --git a/app/Http/Controllers/Care/PractitionerController.php b/app/Http/Controllers/Care/PractitionerController.php index 5be665d..e2583f4 100644 --- a/app/Http/Controllers/Care/PractitionerController.php +++ b/app/Http/Controllers/Care/PractitionerController.php @@ -186,10 +186,12 @@ class PractitionerController extends Controller protected function activeDepartments(string $owner, int $organizationId) { return Department::owned($owner) + ->with('branch') ->whereHas('branch', fn ($q) => $q->where('organization_id', $organizationId)) ->where('is_active', true) - ->orderBy('name') - ->get(); + ->get() + ->sortBy(fn (Department $d) => strtolower(($d->branch?->name ?? '').' '.$d->name)) + ->values(); } protected function linkableMembers(string $owner, int $organizationId) diff --git a/app/Models/Department.php b/app/Models/Department.php index 45439af..123a1e5 100644 --- a/app/Models/Department.php +++ b/app/Models/Department.php @@ -26,4 +26,20 @@ class Department extends Model { return $this->belongsTo(Branch::class, 'branch_id'); } + + /** + * Select option label — includes branch when set so multi-branch duplicates are distinguishable. + */ + public function labelForSelect(): string + { + $branch = $this->relationLoaded('branch') + ? $this->branch + : $this->branch()->first(); + + if ($branch?->name) { + return $this->name.' — '.$branch->name; + } + + return (string) $this->name; + } } diff --git a/app/Models/Practitioner.php b/app/Models/Practitioner.php index af89a2b..448f075 100644 --- a/app/Models/Practitioner.php +++ b/app/Models/Practitioner.php @@ -64,6 +64,30 @@ class Practitioner extends Model return $this->hasMany(Appointment::class, 'practitioner_id'); } + /** + * Select option label — includes branch context when available. + */ + public function labelForSelect(): string + { + $branchNames = []; + if ($this->relationLoaded('branches') && $this->branches->isNotEmpty()) { + $branchNames = $this->branches->pluck('name')->filter()->values()->all(); + } elseif ($this->relationLoaded('branch') && $this->branch?->name) { + $branchNames = [$this->branch->name]; + } elseif ($this->branch_id) { + $name = $this->branch()?->value('name'); + if ($name) { + $branchNames = [$name]; + } + } + + if ($branchNames === []) { + return (string) $this->name; + } + + return $this->name.' — '.implode(', ', $branchNames); + } + /** * @return list */ diff --git a/resources/js/app.js b/resources/js/app.js index f27aecf..4ae877d 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -172,5 +172,48 @@ Alpine.data('dealForm', (initialLines = [], products = []) => ({ }, })); +// Searchable select for long option lists (patients, etc.). +Alpine.data('searchableSelect', (config = {}) => ({ + options: Array.isArray(config.options) ? config.options : [], + selected: config.selected == null ? '' : String(config.selected), + placeholder: config.placeholder || 'Search…', + emptyLabel: config.emptyLabel || 'Select…', + query: '', + open: false, + toggle() { + this.open = !this.open; + if (this.open) { + this.$nextTick(() => this.$refs.query?.focus()); + } + }, + selectedLabel() { + if (! this.selected) { + return this.emptyLabel; + } + const match = this.options.find((o) => String(o.value) === String(this.selected)); + return match?.label || this.emptyLabel; + }, + get filtered() { + const q = this.query.trim().toLowerCase(); + if (! q) { + return this.options; + } + return this.options.filter((o) => { + const hay = `${o.search || ''} ${o.label || ''}`.toLowerCase(); + return hay.includes(q); + }); + }, + choose(value) { + this.selected = value == null ? '' : String(value); + this.open = false; + this.query = ''; + }, + selectFirst() { + if (this.filtered.length > 0) { + this.choose(this.filtered[0].value); + } + }, +})); + window.Alpine = Alpine; Alpine.start(); diff --git a/resources/views/care/admin/practitioners/create.blade.php b/resources/views/care/admin/practitioners/create.blade.php index 636fe9e..8ae726c 100644 --- a/resources/views/care/admin/practitioners/create.blade.php +++ b/resources/views/care/admin/practitioners/create.blade.php @@ -37,7 +37,7 @@ diff --git a/resources/views/care/admin/practitioners/edit.blade.php b/resources/views/care/admin/practitioners/edit.blade.php index 8148b94..5ffa998 100644 --- a/resources/views/care/admin/practitioners/edit.blade.php +++ b/resources/views/care/admin/practitioners/edit.blade.php @@ -39,7 +39,7 @@ diff --git a/resources/views/care/appointments/_form.blade.php b/resources/views/care/appointments/_form.blade.php index 30766b3..2f1f7a3 100644 --- a/resources/views/care/appointments/_form.blade.php +++ b/resources/views/care/appointments/_form.blade.php @@ -1,5 +1,10 @@ @php $isWalkIn = $isWalkIn ?? false; + $patientOptions = collect($patients)->map(fn ($patient) => [ + 'value' => (string) $patient->id, + 'label' => $patient->fullName().' ('.$patient->patient_number.')', + 'search' => trim($patient->fullName().' '.$patient->patient_number.' '.($patient->phone ?? '').' '.($patient->email ?? '')), + ])->all(); @endphp
@@ -13,19 +18,21 @@
- +
@@ -34,7 +41,7 @@ diff --git a/resources/views/components/searchable-select.blade.php b/resources/views/components/searchable-select.blade.php new file mode 100644 index 0000000..361ab2e --- /dev/null +++ b/resources/views/components/searchable-select.blade.php @@ -0,0 +1,90 @@ +@props([ + 'name', + 'options' => [], + 'selected' => null, + 'placeholder' => 'Search…', + 'required' => false, + 'emptyLabel' => 'Select…', +]) + +@php + $optionList = collect($options)->map(function ($option) { + if (is_array($option)) { + return [ + 'value' => (string) ($option['value'] ?? ''), + 'label' => (string) ($option['label'] ?? ''), + 'search' => (string) ($option['search'] ?? $option['label'] ?? ''), + ]; + } + + return [ + 'value' => (string) data_get($option, 'value', data_get($option, 'id', '')), + 'label' => (string) data_get($option, 'label', data_get($option, 'name', '')), + 'search' => (string) data_get($option, 'search', data_get($option, 'label', data_get($option, 'name', ''))), + ]; + })->values()->all(); + + $selectedValue = old($name, $selected); + $selectedValue = $selectedValue === null || $selectedValue === '' ? '' : (string) $selectedValue; +@endphp + +
+ except(['class']) }}> + + + +
+
+ +
+
    +
  • + +
  • + +
  • No matches
  • +
+
+
diff --git a/tests/Feature/CareSelectLabelsTest.php b/tests/Feature/CareSelectLabelsTest.php new file mode 100644 index 0000000..26ce645 --- /dev/null +++ b/tests/Feature/CareSelectLabelsTest.php @@ -0,0 +1,105 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $owner = User::create([ + 'public_id' => 'select-owner', + 'name' => 'Owner', + 'email' => 'select-owner@example.com', + ]); + + $organization = Organization::create([ + 'owner_ref' => $owner->public_id, + 'name' => 'Select Clinic', + 'slug' => 'select-clinic', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'clinic', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ], + ]); + + Member::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'user_ref' => $owner->public_id, + 'role' => 'hospital_admin', + ]); + + $east = Branch::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'name' => 'East', + 'is_active' => true, + ]); + $west = Branch::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'name' => 'West', + 'is_active' => true, + ]); + + Department::create([ + 'owner_ref' => $owner->public_id, + 'branch_id' => $east->id, + 'name' => 'Dentistry', + 'type' => 'dental', + 'is_active' => true, + ]); + Department::create([ + 'owner_ref' => $owner->public_id, + 'branch_id' => $west->id, + 'name' => 'Dentistry', + 'type' => 'dental', + 'is_active' => true, + ]); + + Patient::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'branch_id' => $east->id, + 'patient_number' => 'P-SEL-1', + 'first_name' => 'Kofi', + 'last_name' => 'Mensah', + 'gender' => 'male', + 'date_of_birth' => '1990-01-01', + 'phone' => '0244111222', + ]); + + $this->actingAs($owner) + ->get(route('care.appointments.create')) + ->assertOk() + ->assertSee('Dentistry — East') + ->assertSee('Dentistry — West') + ->assertSee('Search name, number, phone') + ->assertSee('Kofi Mensah (P-SEL-1)'); + } + + public function test_department_label_for_select_includes_branch(): void + { + $branch = new Branch(['name' => 'Main']); + $department = new Department(['name' => 'Cardiology']); + $department->setRelation('branch', $branch); + + $this->assertSame('Cardiology — Main', $department->labelForSelect()); + } +}