Lock doctors to their assigned patients and hide branch filters.
Deploy Ladill Care / deploy (push) Successful in 1m2s

Doctors no longer see branch/practitioner pickers; queue, appointments, patients, and dashboard only include visits on their linked desk.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 06:28:07 +00:00
co-authored by Cursor
parent 5264035705
commit dcbc62d1d3
11 changed files with 361 additions and 89 deletions
@@ -34,21 +34,35 @@ class AppointmentController extends Controller
$this->authorizeAbility($request, 'appointments.view');
$organization = $this->organization($request);
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$resolver = app(OrganizationResolver::class);
$branchScope = $resolver->branchScope($member);
$practitionerScope = $resolver->practitionerScope($organization, $member);
$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,
$request->only(['status', 'practitioner_id', 'date', 'patient_id']),
$branchScope,
$filters,
$practitionerScope === null ? $branchScope : null,
$practitionerScope,
);
$practitioners = $this->activePractitioners($request, $organization->id);
$practitioners = $practitionerScope === null
? $this->activePractitioners($request, $organization->id)
: collect();
$owner = $this->ownerRef($request);
$appointmentQuery = Appointment::owned($owner)
->where('organization_id', $organization->id)
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope));
->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(),
@@ -63,6 +77,7 @@ class AppointmentController extends Controller
'organization' => $organization,
'appointments' => $appointments,
'practitioners' => $practitioners,
'lockToPractitioner' => $practitionerScope !== null,
'statuses' => config('care.appointment_statuses'),
'heroStats' => $heroStats,
]);
@@ -109,6 +109,23 @@ class DashboardController extends Controller
?Member $member,
?int $branchScope,
): array {
$organization = $this->organization($request);
$practitionerScope = app(OrganizationResolver::class)->practitionerScope($organization, $member);
if ($practitionerScope !== null) {
$queue = $this->appointments->queueForPractitioners($owner, $practitionerScope);
$inConsultation = Appointment::owned($owner)
->where('organization_id', $organizationId)
->where('status', Appointment::STATUS_IN_CONSULTATION)
->whereIn('practitioner_id', $practitionerScope ?: [0])
->with(['patient', 'practitioner', 'consultation'])
->orderBy('started_at')
->limit(20)
->get();
return [$queue, $inConsultation, $practitionerScope[0] ?? null];
}
$practitioner = null;
if ($member) {
$practitioner = Practitioner::owned($owner)
@@ -27,19 +27,34 @@ class PatientController extends Controller
{
$this->authorizeAbility($request, 'patients.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$member = $this->member($request);
$resolver = app(OrganizationResolver::class);
$branchScope = $resolver->branchScope($member);
$practitionerScope = $resolver->practitionerScope($organization, $member);
$patients = $this->patients->search(
$this->ownerRef($request),
$organization->id,
$request->only(['q', 'patient_number', 'phone', 'national_id', 'date_of_birth']),
$branchScope,
$practitionerScope === null ? $branchScope : null,
$practitionerScope,
);
$owner = $this->ownerRef($request);
$patientQuery = Patient::owned($owner)
->where('organization_id', $organization->id)
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope));
->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(),
+86 -32
View File
@@ -33,43 +33,84 @@ class QueueController extends Controller
$this->authorizeAbility($request, 'appointments.view');
$organization = $this->organization($request);
$member = $this->member($request);
$owner = $this->ownerRef($request);
$resolver = app(OrganizationResolver::class);
$practitionerScope = $resolver->practitionerScope($organization, $member);
$lockToPractitioner = $practitionerScope !== null;
$branches = $this->branchContext->branches($request, $organization, $member);
$branchId = $this->branchContext->resolve($request, $organization, $member);
$practitionerId = $request->input('practitioner_id') ? (int) $request->input('practitioner_id') : null;
$practitionerId = $lockToPractitioner
? ($practitionerScope[0] ?? null)
: ($request->input('practitioner_id') ? (int) $request->input('practitioner_id') : null);
$queueIntegration = ['enabled' => false];
if ($branchId && $this->queueBridge->isEnabled($organization)) {
// Catch up tickets for every waiting appointment (consultation + specialties).
$this->queueBridge->syncMissingAppointmentTickets($organization, $branchId, 100);
$queueIntegration = $this->queueBridge->listMeta($organization, CareQueueContexts::CONSULTATION, $branchId);
if ($this->queueBridge->isEnabled($organization)) {
$syncBranches = $lockToPractitioner
? Practitioner::owned($owner)
->whereIn('id', $practitionerScope ?: [0])
->pluck('branch_id')
->map(fn ($id) => (int) $id)
->unique()
->filter()
->values()
->all()
: array_filter([$branchId]);
foreach ($syncBranches as $syncBranchId) {
$this->queueBridge->syncMissingAppointmentTickets($organization, $syncBranchId, 100);
}
if ($branchId > 0) {
$queueIntegration = $this->queueBridge->listMeta($organization, CareQueueContexts::CONSULTATION, $branchId);
} elseif ($syncBranches !== []) {
$queueIntegration = $this->queueBridge->listMeta(
$organization,
CareQueueContexts::CONSULTATION,
(int) $syncBranches[0],
);
}
}
$queue = $branchId
? $this->appointments->queue($this->ownerRef($request), $branchId, $practitionerId)
: collect();
if ($lockToPractitioner) {
$queue = $this->appointments->queueForPractitioners($owner, $practitionerScope ?? []);
$inConsultation = Appointment::owned($owner)
->where('organization_id', $organization->id)
->where('status', Appointment::STATUS_IN_CONSULTATION)
->whereIn('practitioner_id', $practitionerScope ?: [0])
->with(['patient', 'practitioner', 'consultation'])
->orderBy('started_at')
->get();
} else {
$queue = $branchId
? $this->appointments->queue($owner, $branchId, $practitionerId)
: collect();
$inConsultation = Appointment::owned($owner)
->where('organization_id', $organization->id)
->where('status', Appointment::STATUS_IN_CONSULTATION)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId))
->with(['patient', 'practitioner', 'consultation'])
->orderBy('started_at')
->get();
}
$inConsultation = Appointment::owned($this->ownerRef($request))
$canManageQueue = app(CarePermissions::class)->can($member, 'queue.manage');
$canConsult = app(CarePermissions::class)->can($member, 'consultations.manage');
$todayQuery = Appointment::owned($owner)
->where('organization_id', $organization->id)
->where('status', Appointment::STATUS_IN_CONSULTATION)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId))
->with(['patient', 'practitioner', 'consultation'])
->orderBy('started_at')
->get();
$canManageQueue = app(CarePermissions::class)
->can($this->member($request), 'queue.manage');
$canConsult = app(CarePermissions::class)
->can($this->member($request), 'consultations.manage');
->whereDate('scheduled_at', today());
if ($lockToPractitioner) {
$todayQuery->whereIn('practitioner_id', $practitionerScope ?: [0]);
} elseif ($branchId) {
$todayQuery->where('branch_id', $branchId);
}
$heroStats = [
'waiting' => $queue->count(),
'in_consultation' => $inConsultation->count(),
'today' => Appointment::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereDate('scheduled_at', today())
->count(),
'today' => $todayQuery->count(),
];
return view('care.queue.index', [
@@ -77,12 +118,15 @@ class QueueController extends Controller
'branches' => $branches,
'branchId' => $branchId,
'canSwitchBranch' => $this->branchContext->canSwitch($member),
'practitioners' => Practitioner::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderBy('name')
->get(),
'lockToPractitioner' => $lockToPractitioner,
'practitioners' => $lockToPractitioner
? collect()
: Practitioner::owned($owner)
->where('organization_id', $organization->id)
->where('is_active', true)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderBy('name')
->get(),
'practitionerId' => $practitionerId,
'queue' => $queue,
'inConsultation' => $inConsultation,
@@ -100,7 +144,17 @@ class QueueController extends Controller
abort_unless($this->queueBridge->isEnabled($organization), 404);
$member = $this->member($request);
$practitionerScope = app(OrganizationResolver::class)->practitionerScope($organization, $member);
$branchId = $this->branchContext->resolve($request, $organization, $member);
$practitionerId = $request->input('practitioner_id') ? (int) $request->input('practitioner_id') : null;
if ($practitionerScope !== null) {
abort_unless($practitionerScope !== [], 422);
$practitionerId = $practitionerScope[0];
$prac = Practitioner::owned($this->ownerRef($request))->find($practitionerId);
$branchId = (int) ($prac?->branch_id ?: $branchId);
}
abort_unless($branchId > 0, 422);
$result = $this->queueBridge->callNext(
@@ -108,7 +162,7 @@ class QueueController extends Controller
CareQueueContexts::CONSULTATION,
$branchId,
$member,
$request->input('practitioner_id') ? (int) $request->input('practitioner_id') : null,
$practitionerId,
);
$ticket = $result['ticket'] ?? null;
if (! $ticket) {
+73 -6
View File
@@ -38,9 +38,15 @@ class AppointmentService
/**
* @param array<string, mixed> $filters
* @param list<int>|null $practitionerIds When set, only appointments assigned to these desks.
*/
public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
{
public function list(
string $ownerRef,
int $organizationId,
array $filters = [],
?int $branchId = null,
?array $practitionerIds = null,
): LengthAwarePaginator {
$query = $this->queryForOrganization($ownerRef, $organizationId)
->with(['patient', 'practitioner', 'branch', 'department'])
->orderByDesc('scheduled_at');
@@ -49,6 +55,14 @@ class AppointmentService
$query->where('branch_id', $branchId);
}
if ($practitionerIds !== null) {
if ($practitionerIds === []) {
$query->whereRaw('1 = 0');
} else {
$query->whereIn('practitioner_id', $practitionerIds);
}
}
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
@@ -69,10 +83,20 @@ class AppointmentService
}
/**
* @param list<int>|null $practitionerIds
* @return Collection<int, Appointment>
*/
public function queue(string $ownerRef, int $branchId, ?int $practitionerId = null): Collection
{
public function queue(
string $ownerRef,
int $branchId,
?int $practitionerId = null,
bool $includeUnassigned = true,
?array $practitionerIds = null,
): Collection {
if ($practitionerIds !== null) {
return $this->queueForPractitioners($ownerRef, $practitionerIds, $branchId > 0 ? $branchId : null);
}
$this->repairDuplicateQueuePositions($ownerRef, $branchId);
$query = Appointment::owned($ownerRef)
@@ -84,8 +108,11 @@ class AppointmentService
->orderBy('checked_in_at');
if ($practitionerId !== null) {
$query->where(function (Builder $q) use ($practitionerId) {
$q->where('practitioner_id', $practitionerId)->orWhereNull('practitioner_id');
$query->where(function (Builder $q) use ($practitionerId, $includeUnassigned) {
$q->where('practitioner_id', $practitionerId);
if ($includeUnassigned) {
$q->orWhereNull('practitioner_id');
}
});
}
@@ -105,6 +132,46 @@ class AppointmentService
->values();
}
/**
* Doctor queue: only appointments assigned to their linked practitioner desk(s).
*
* @param list<int> $practitionerIds
* @return Collection<int, Appointment>
*/
public function queueForPractitioners(string $ownerRef, array $practitionerIds, ?int $branchId = null): Collection
{
if ($practitionerIds === []) {
return new Collection;
}
$query = Appointment::owned($ownerRef)
->whereIn('practitioner_id', $practitionerIds)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->with(['patient', 'practitioner', 'visit', 'organization'])
->orderBy('queue_position')
->orderBy('waiting_at')
->orderBy('checked_in_at');
return new Collection(
$query->get()
->filter(function (Appointment $appointment) {
$organization = $appointment->organization;
if (! $organization) {
return true;
}
return $this->workflowGate->canRelease(
$organization,
$appointment->visit,
$this->queueBridge->contextForAppointment($organization, $appointment),
);
})
->values()
->all()
);
}
/**
* @param array<string, mixed> $data
*/
+13 -14
View File
@@ -243,22 +243,21 @@ class DemoTenantSeeder
->where('user_ref', $doctorEmail)
->first();
$byBranch = [];
foreach ($practitioners as $practitioner) {
$byBranch[(int) $practitioner->branch_id] ??= $practitioner;
// Link the demo doctor to a single desk on their home branch (not every site).
$homeBranchId = (int) ($member?->branch_id ?: ($branches[0]->id ?? 0));
$practitioner = collect($practitioners)->first(
fn (Practitioner $p) => (int) $p->branch_id === $homeBranchId,
) ?? ($practitioners[0] ?? null);
if (! $practitioner) {
return;
}
foreach ($branches as $branch) {
$practitioner = $byBranch[(int) $branch->id] ?? null;
if (! $practitioner) {
continue;
}
$practitioner->forceFill([
'user_ref' => $doctorEmail,
'member_id' => $member?->id,
'name' => $doctorName !== '' ? $doctorName : $practitioner->name,
])->save();
}
$practitioner->forceFill([
'user_ref' => $doctorEmail,
'member_id' => $member?->id,
'name' => $doctorName !== '' ? $doctorName : $practitioner->name,
])->save();
}
/**
@@ -7,6 +7,7 @@ use App\Models\Department;
use App\Models\FacilityWorkflow;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Practitioner;
use App\Models\User;
use App\Services\Care\Workflow\WorkflowTemplateInstaller;
use App\Services\Care\Workflow\WorkflowTemplateRegistry;
@@ -192,4 +193,30 @@ class OrganizationResolver
return $member->branch_id;
}
/**
* Practitioner IDs a doctor is locked to. Null = no practitioner lock (non-doctors).
* Empty array = doctor with no linked practitioner desk (see no clinical queue).
*
* @return list<int>|null
*/
public function practitionerScope(Organization $organization, ?Member $member): ?array
{
if ($member === null || $member->role !== 'doctor') {
return null;
}
return Practitioner::owned((string) $organization->owner_ref)
->where('organization_id', $organization->id)
->where('is_active', true)
->where(function ($query) use ($member) {
$query->where('member_id', $member->id)
->orWhere('user_ref', $member->user_ref);
})
->orderBy('id')
->pluck('id')
->map(fn ($id) => (int) $id)
->values()
->all();
}
}
+19 -2
View File
@@ -30,9 +30,15 @@ class PatientService
/**
* @param array<string, mixed> $filters
* @param list<int>|null $practitionerIds When set, only patients with appointments on these desks.
*/
public function search(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
{
public function search(
string $ownerRef,
int $organizationId,
array $filters = [],
?int $branchId = null,
?array $practitionerIds = null,
): LengthAwarePaginator {
$query = $this->queryForOrganization($ownerRef, $organizationId)
->with(['branch'])
->orderByDesc('created_at');
@@ -41,6 +47,17 @@ class PatientService
$query->where('branch_id', $branchId);
}
if ($practitionerIds !== null) {
if ($practitionerIds === []) {
$query->whereRaw('1 = 0');
} else {
$query->whereHas(
'appointments',
fn (Builder $inner) => $inner->whereIn('practitioner_id', $practitionerIds),
);
}
}
if ($q = trim((string) ($filters['q'] ?? ''))) {
$query->where(function (Builder $inner) use ($q) {
$inner->where('patient_number', 'like', "%{$q}%")
@@ -32,12 +32,14 @@
<option value="{{ $value }}" @selected(request('status') === $value)>{{ $label }}</option>
@endforeach
</select>
<select name="practitioner_id" class="rounded-lg border-slate-300 text-sm">
<option value="">All practitioners</option>
@foreach ($practitioners as $practitioner)
<option value="{{ $practitioner->id }}" @selected((string) request('practitioner_id') === (string) $practitioner->id)>{{ $practitioner->name }}</option>
@endforeach
</select>
@unless ($lockToPractitioner ?? false)
<select name="practitioner_id" class="rounded-lg border-slate-300 text-sm">
<option value="">All practitioners</option>
@foreach ($practitioners as $practitioner)
<option value="{{ $practitioner->id }}" @selected((string) request('practitioner_id') === (string) $practitioner->id)>{{ $practitioner->name }}</option>
@endforeach
</select>
@endunless
<button type="submit" class="btn-primary">Filter</button>
@if (request()->hasAny(['date', 'status', 'practitioner_id']))
<a href="{{ route('care.appointments.index') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600">Clear</a>
+22 -12
View File
@@ -17,18 +17,28 @@
</x-care.page-hero>
<form method="GET" class="flex flex-wrap gap-3 rounded-2xl border border-slate-200 bg-white p-4">
<select name="branch_id" class="rounded-lg border-slate-300 text-sm" onchange="this.form.submit()">
@foreach ($branches as $branch)
<option value="{{ $branch->id }}" @selected($branchId == $branch->id)>{{ $branch->name }}</option>
@endforeach
</select>
<select name="practitioner_id" class="rounded-lg border-slate-300 text-sm">
<option value="">All practitioners</option>
@foreach ($practitioners as $practitioner)
<option value="{{ $practitioner->id }}" @selected($practitionerId == $practitioner->id)>{{ $practitioner->name }}</option>
@endforeach
</select>
<button type="submit" class="btn-primary">Filter</button>
@if ($canSwitchBranch ?? false)
<select name="branch_id" class="rounded-lg border-slate-300 text-sm" onchange="this.form.submit()">
@foreach ($branches as $branch)
<option value="{{ $branch->id }}" @selected($branchId == $branch->id)>{{ $branch->name }}</option>
@endforeach
</select>
@elseif (($lockToPractitioner ?? false) === false && isset($branches) && $branches->isNotEmpty())
<input type="hidden" name="branch_id" value="{{ $branchId }}">
<span class="flex items-center text-sm text-slate-500">{{ $branches->first()->name }}</span>
@endif
@unless ($lockToPractitioner ?? false)
<select name="practitioner_id" class="rounded-lg border-slate-300 text-sm">
<option value="">All practitioners</option>
@foreach ($practitioners as $practitioner)
<option value="{{ $practitioner->id }}" @selected($practitionerId == $practitioner->id)>{{ $practitioner->name }}</option>
@endforeach
</select>
<button type="submit" class="btn-primary">Filter</button>
@else
<span class="flex items-center text-sm text-slate-500">Showing patients assigned to you</span>
@endunless
</form>
@include('care.partials.queue-ops', [
+56 -7
View File
@@ -8,6 +8,7 @@ use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Practitioner;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -16,7 +17,7 @@ class CareStaffTenantScopeTest extends TestCase
{
use RefreshDatabase;
public function test_staff_doctor_sees_employer_queue_and_patients(): void
public function test_staff_doctor_sees_only_assigned_patients_without_branch_filter(): void
{
$this->withoutMiddleware(EnsurePlatformSession::class);
@@ -59,7 +60,7 @@ class CareStaffTenantScopeTest extends TestCase
'is_active' => true,
]);
Member::create([
$doctorMember = Member::create([
'owner_ref' => $owner->public_id,
'organization_id' => $organization->id,
'user_ref' => $doctor->public_id,
@@ -67,7 +68,25 @@ class CareStaffTenantScopeTest extends TestCase
'branch_id' => $branch->id,
]);
$patient = Patient::create([
$mine = Practitioner::create([
'owner_ref' => $owner->public_id,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'member_id' => $doctorMember->id,
'user_ref' => $doctor->public_id,
'name' => 'Demo Pro Doctor',
'is_active' => true,
]);
$other = Practitioner::create([
'owner_ref' => $owner->public_id,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'name' => 'Other Doctor',
'is_active' => true,
]);
$assignedPatient = Patient::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $owner->public_id,
'organization_id' => $organization->id,
@@ -77,12 +96,23 @@ class CareStaffTenantScopeTest extends TestCase
'last_name' => 'Mensah',
]);
$otherPatient = Patient::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $owner->public_id,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_number' => 'DEMO-8883-00002',
'first_name' => 'Kofi',
'last_name' => 'Boateng',
]);
Appointment::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $owner->public_id,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'patient_id' => $assignedPatient->id,
'practitioner_id' => $mine->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'scheduled_at' => now(),
@@ -92,18 +122,37 @@ class CareStaffTenantScopeTest extends TestCase
'created_by' => $owner->public_id,
]);
Appointment::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $owner->public_id,
'organization_id' => $organization->id,
'branch_id' => $branch->id,
'patient_id' => $otherPatient->id,
'practitioner_id' => $other->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'scheduled_at' => now(),
'waiting_at' => now(),
'queue_position' => 2,
'reason' => 'Fever',
'created_by' => $owner->public_id,
]);
$this->actingAs($doctor)
->get(route('care.queue.index'))
->assertOk()
->assertSee('East Legon Care')
->assertSee('Showing patients assigned to you')
->assertDontSee('All practitioners')
->assertDontSee('name="branch_id"', false)
->assertSee('Ama Mensah')
->assertSee('Toothache')
->assertDontSee('No patients waiting.');
->assertDontSee('Kofi Boateng')
->assertDontSee('Fever');
$this->actingAs($doctor)
->get(route('care.patients.index'))
->assertOk()
->assertSee('Ama Mensah')
->assertSee('DEMO-8883-00001');
->assertDontSee('Kofi Boateng');
}
}