Label multi-branch department picks and add searchable patients.
Deploy Ladill Care / deploy (push) Successful in 27s
Deploy Ladill Care / deploy (push) Successful in 27s
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>
This commit is contained in:
@@ -276,10 +276,12 @@ class AppointmentController extends Controller
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
$departments = Department::owned($ownerRef)
|
$departments = Department::owned($ownerRef)
|
||||||
|
->with('branch')
|
||||||
->whereIn('branch_id', $branches->pluck('id'))
|
->whereIn('branch_id', $branches->pluck('id'))
|
||||||
->where('is_active', true)
|
->where('is_active', true)
|
||||||
->orderBy('name')
|
->get()
|
||||||
->get();
|
->sortBy(fn (Department $d) => strtolower(($d->branch?->name ?? '').' '.$d->name))
|
||||||
|
->values();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'organization' => $organization,
|
'organization' => $organization,
|
||||||
@@ -297,6 +299,7 @@ class AppointmentController extends Controller
|
|||||||
protected function activePractitioners(Request $request, int $organizationId)
|
protected function activePractitioners(Request $request, int $organizationId)
|
||||||
{
|
{
|
||||||
return Practitioner::owned($this->ownerRef($request))
|
return Practitioner::owned($this->ownerRef($request))
|
||||||
|
->with(['branch', 'branches'])
|
||||||
->where('organization_id', $organizationId)
|
->where('organization_id', $organizationId)
|
||||||
->where('is_active', true)
|
->where('is_active', true)
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
|
|||||||
@@ -186,10 +186,12 @@ class PractitionerController extends Controller
|
|||||||
protected function activeDepartments(string $owner, int $organizationId)
|
protected function activeDepartments(string $owner, int $organizationId)
|
||||||
{
|
{
|
||||||
return Department::owned($owner)
|
return Department::owned($owner)
|
||||||
|
->with('branch')
|
||||||
->whereHas('branch', fn ($q) => $q->where('organization_id', $organizationId))
|
->whereHas('branch', fn ($q) => $q->where('organization_id', $organizationId))
|
||||||
->where('is_active', true)
|
->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)
|
protected function linkableMembers(string $owner, int $organizationId)
|
||||||
|
|||||||
@@ -26,4 +26,20 @@ class Department extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Branch::class, 'branch_id');
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,30 @@ class Practitioner extends Model
|
|||||||
return $this->hasMany(Appointment::class, 'practitioner_id');
|
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<int>
|
* @return list<int>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
window.Alpine = Alpine;
|
||||||
Alpine.start();
|
Alpine.start();
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<select name="department_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="department_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
@foreach ($departments as $department)
|
@foreach ($departments as $department)
|
||||||
<option value="{{ $department->id }}" @selected(old('department_id') == $department->id)>{{ $department->name }}</option>
|
<option value="{{ $department->id }}" @selected(old('department_id') == $department->id)>{{ $department->labelForSelect() }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<select name="department_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="department_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
@foreach ($departments as $department)
|
@foreach ($departments as $department)
|
||||||
<option value="{{ $department->id }}" @selected(old('department_id', $practitioner->department_id) == $department->id)>{{ $department->name }}</option>
|
<option value="{{ $department->id }}" @selected(old('department_id', $practitioner->department_id) == $department->id)>{{ $department->labelForSelect() }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
@php
|
@php
|
||||||
$isWalkIn = $isWalkIn ?? false;
|
$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
|
@endphp
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
@@ -13,19 +18,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700">Patient</label>
|
<label class="block text-sm font-medium text-slate-700">Patient</label>
|
||||||
<select name="patient_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<x-searchable-select
|
||||||
<option value="">Select patient…</option>
|
name="patient_id"
|
||||||
@foreach ($patients as $patient)
|
:options="$patientOptions"
|
||||||
<option value="{{ $patient->id }}" @selected(old('patient_id') == $patient->id)>{{ $patient->fullName() }} ({{ $patient->patient_number }})</option>
|
:selected="old('patient_id')"
|
||||||
@endforeach
|
:required="true"
|
||||||
</select>
|
placeholder="Search name, number, phone…"
|
||||||
|
empty-label="Select patient…"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700">Practitioner</label>
|
<label class="block text-sm font-medium text-slate-700">Practitioner</label>
|
||||||
<select name="practitioner_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="practitioner_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
<option value="">Any / unassigned</option>
|
<option value="">Any / unassigned</option>
|
||||||
@foreach ($practitioners as $practitioner)
|
@foreach ($practitioners as $practitioner)
|
||||||
<option value="{{ $practitioner->id }}" @selected(old('practitioner_id') == $practitioner->id)>{{ $practitioner->name }}</option>
|
<option value="{{ $practitioner->id }}" @selected(old('practitioner_id') == $practitioner->id)>{{ $practitioner->labelForSelect() }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +41,7 @@
|
|||||||
<select name="department_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="department_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
@foreach ($departments as $department)
|
@foreach ($departments as $department)
|
||||||
<option value="{{ $department->id }}" @selected(old('department_id') == $department->id)>{{ $department->name }}</option>
|
<option value="{{ $department->id }}" @selected(old('department_id') == $department->id)>{{ $department->labelForSelect() }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
x-data="searchableSelect({
|
||||||
|
options: {{ \Illuminate\Support\Js::from($optionList) }},
|
||||||
|
selected: {{ \Illuminate\Support\Js::from($selectedValue) }},
|
||||||
|
placeholder: {{ \Illuminate\Support\Js::from($placeholder) }},
|
||||||
|
emptyLabel: {{ \Illuminate\Support\Js::from($emptyLabel) }},
|
||||||
|
})"
|
||||||
|
@keydown.escape.window="open = false"
|
||||||
|
@click.outside="open = false"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="{{ $name }}" x-model="selected" @if ($required) required @endif {{ $attributes->except(['class']) }}>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-1 flex w-full items-center justify-between rounded-lg border border-slate-300 bg-white px-3 py-2 text-left text-sm text-slate-900 shadow-sm hover:border-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
@click="toggle()"
|
||||||
|
:aria-expanded="open.toString()"
|
||||||
|
>
|
||||||
|
<span class="truncate" x-text="selectedLabel()"></span>
|
||||||
|
<svg class="ml-2 h-4 w-4 shrink-0 text-slate-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-show="open"
|
||||||
|
x-cloak
|
||||||
|
x-transition.opacity
|
||||||
|
class="absolute z-30 mt-1 w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="border-b border-slate-100 p-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
x-ref="query"
|
||||||
|
x-model="query"
|
||||||
|
@keydown.enter.prevent="selectFirst()"
|
||||||
|
placeholder="{{ $placeholder }}"
|
||||||
|
class="w-full rounded-lg border-slate-300 text-sm"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<ul class="max-h-56 overflow-y-auto py-1 text-sm">
|
||||||
|
<li>
|
||||||
|
<button type="button" class="flex w-full px-3 py-2 text-left text-slate-500 hover:bg-slate-50" @click="choose('')">
|
||||||
|
{{ $emptyLabel }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<template x-for="option in filtered" :key="option.value">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full px-3 py-2 text-left hover:bg-indigo-50"
|
||||||
|
:class="selected === option.value ? 'bg-indigo-50 font-medium text-indigo-900' : 'text-slate-800'"
|
||||||
|
@click="choose(option.value)"
|
||||||
|
x-text="option.label"
|
||||||
|
></button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<li x-show="filtered.length === 0" class="px-3 py-2 text-slate-400">No matches</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Department;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CareSelectLabelsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_appointment_form_shows_branch_on_department_options_and_searchable_patient(): void
|
||||||
|
{
|
||||||
|
$this->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user