Add shift templates, unit duty roster grid, and Nursing Services hub.
Deploy Ladill Care / deploy (push) Successful in 1m0s

Phase 3 staff management: real shift entities, week roster linked to temporary assignments, and a nursing ops module surface (registry, today’s allocation, unit shortcuts).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-20 10:40:25 +00:00
co-authored by Cursor
parent 9eb6c21828
commit b2cebe2908
20 changed files with 1439 additions and 8 deletions
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Services\Care\CarePermissions;
use App\Services\Care\NursingServicesService;
use App\Services\Care\RosterService;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NursingServicesController extends Controller
{
use ScopesToAccount;
public function index(
Request $request,
NursingServicesService $nursing,
RosterService $roster,
SpecialtyModuleService $modules,
): View {
$this->authorizeAbility($request, 'nursing.services.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$nurses = $nursing->nurseRegistry($organization, $owner);
$allocations = $nursing->activeAllocations($organization, $owner);
$todayRoster = $roster->todayAllocation($organization, $owner);
$units = $nursing->careUnits($organization, $owner);
$moduleEnabled = $modules->isEnabled($organization, 'nursing_services');
$labelMembers = $nurses
->merge($allocations->pluck('member')->filter())
->merge($todayRoster->pluck('member')->filter())
->unique('id')
->values();
$permissions = app(CarePermissions::class);
$member = $this->member($request);
return view('care.nursing.services-hub', [
'organization' => $organization,
'nurses' => $nurses,
'nurseLabels' => $nursing->memberLabels($labelMembers),
'allocations' => $allocations,
'todayRoster' => $todayRoster,
'units' => $units,
'moduleEnabled' => $moduleEnabled,
'roles' => config('care.roles'),
'canRoster' => $permissions->can($member, 'nursing.roster.manage'),
'canViewRoster' => $permissions->can($member, 'nursing.roster.view'),
'assignmentRoles' => config('care.staff_assignment_roles'),
'shiftCodes' => config('care.staff_shift_codes'),
]);
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\CareUnit;
use App\Models\Member;
use App\Models\RosterEntry;
use App\Models\Shift;
use App\Services\Care\CarePermissions;
use App\Services\Care\RosterService;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class RosterController extends Controller
{
use ScopesToAccount;
public function show(Request $request, CareUnit $careUnit, RosterService $roster): View
{
$this->authorizeAbility($request, 'nursing.roster.view');
$this->authorizeOwner($request, $careUnit);
abort_unless((int) $careUnit->organization_id === (int) $this->organization($request)->id, 404);
$weekStart = $request->filled('week')
? Carbon::parse($request->input('week'))->startOfWeek(Carbon::MONDAY)
: now()->startOfWeek(Carbon::MONDAY);
$grid = $roster->unitWeekGrid($careUnit, $this->ownerRef($request), $weekStart);
$canManage = app(CarePermissions::class)->can($this->member($request), 'nursing.roster.manage');
return view('care.nursing.roster', [
'unit' => $careUnit->load('department.branch'),
'weekStart' => $grid['week_start'],
'days' => $grid['days'],
'shifts' => $grid['shifts'],
'cells' => $grid['cells'],
'members' => $grid['members'],
'memberLabels' => $grid['member_labels'],
'canManage' => $canManage,
'statuses' => config('care.roster_entry_statuses'),
'prevWeek' => $grid['week_start']->copy()->subWeek()->toDateString(),
'nextWeek' => $grid['week_start']->copy()->addWeek()->toDateString(),
]);
}
public function store(Request $request, CareUnit $careUnit, RosterService $roster): RedirectResponse
{
$this->authorizeAbility($request, 'nursing.roster.manage');
$this->authorizeOwner($request, $careUnit);
$organization = $this->organization($request);
abort_unless((int) $careUnit->organization_id === (int) $organization->id, 404);
$validated = $request->validate([
'shift_id' => ['required', 'integer', 'exists:care_shifts,id'],
'member_id' => ['required', 'integer', 'exists:care_members,id'],
'duty_date' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:1000'],
]);
$shift = Shift::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->findOrFail($validated['shift_id']);
$member = Member::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->findOrFail($validated['member_id']);
$roster->assign(
$careUnit,
$shift,
$member,
$validated['duty_date'],
$this->ownerRef($request),
$this->actorRef($request),
$validated['notes'] ?? null,
);
return redirect()
->route('care.care-units.roster', [
'careUnit' => $careUnit,
'week' => Carbon::parse($validated['duty_date'])->startOfWeek(Carbon::MONDAY)->toDateString(),
])
->with('success', 'Staff added to roster.');
}
public function destroy(Request $request, CareUnit $careUnit, RosterEntry $rosterEntry, RosterService $roster): RedirectResponse
{
$this->authorizeAbility($request, 'nursing.roster.manage');
$this->authorizeOwner($request, $careUnit);
$this->authorizeOwner($request, $rosterEntry);
abort_unless((int) $rosterEntry->care_unit_id === (int) $careUnit->id, 404);
$week = $rosterEntry->duty_date->copy()->startOfWeek(Carbon::MONDAY)->toDateString();
$roster->cancel($rosterEntry, $this->ownerRef($request), $this->actorRef($request));
return redirect()
->route('care.care-units.roster', ['careUnit' => $careUnit, 'week' => $week])
->with('success', 'Roster entry cancelled.');
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Shift;
use App\Services\Care\AuditLogger;
use App\Services\Care\RosterService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class ShiftController extends Controller
{
use ScopesToAccount;
public function index(Request $request, RosterService $roster): View
{
$this->authorizeAbility($request, 'nursing.roster.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$roster->ensureDefaultShifts($organization, $owner);
$shifts = Shift::owned($owner)
->where('organization_id', $organization->id)
->orderBy('sort_order')
->orderBy('label')
->get();
return view('care.nursing.shifts-index', [
'shifts' => $shifts,
'organization' => $organization,
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'nursing.roster.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'code' => [
'required', 'string', 'max:50', 'alpha_dash',
Rule::unique('care_shifts', 'code')->where(fn ($q) => $q->where('organization_id', $organization->id)->whereNull('deleted_at')),
],
'label' => ['required', 'string', 'max:100'],
'start_time' => ['required', 'date_format:H:i'],
'end_time' => ['required', 'date_format:H:i'],
'color' => ['nullable', 'string', 'max:20'],
]);
$shift = Shift::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
'code' => strtolower($validated['code']),
'label' => $validated['label'],
'start_time' => $validated['start_time'].':00',
'end_time' => $validated['end_time'].':00',
'color' => $validated['color'] ?? '#64748b',
'sort_order' => ((int) Shift::query()->where('organization_id', $organization->id)->max('sort_order')) + 10,
'is_active' => true,
]);
AuditLogger::record($owner, 'shift.created', $organization->id, $owner, Shift::class, $shift->id);
return redirect()->route('care.shifts.index')->with('success', 'Shift created.');
}
public function update(Request $request, Shift $shift): RedirectResponse
{
$this->authorizeAbility($request, 'nursing.roster.manage');
$this->authorizeOwner($request, $shift);
$validated = $request->validate([
'label' => ['required', 'string', 'max:100'],
'start_time' => ['required', 'date_format:H:i'],
'end_time' => ['required', 'date_format:H:i'],
'color' => ['nullable', 'string', 'max:20'],
'is_active' => ['boolean'],
]);
$shift->update([
'label' => $validated['label'],
'start_time' => $validated['start_time'].':00',
'end_time' => $validated['end_time'].':00',
'color' => $validated['color'] ?? $shift->color,
'is_active' => $request->boolean('is_active', true),
]);
AuditLogger::record($this->ownerRef($request), 'shift.updated', $shift->organization_id, $this->ownerRef($request), Shift::class, $shift->id);
return redirect()->route('care.shifts.index')->with('success', 'Shift updated.');
}
}
@@ -31,7 +31,12 @@ class SpecialtyModuleController extends Controller
SpecialtyModuleService $modules,
SpecialtyShellService $shell,
CareQueueBridge $queueBridge,
): View {
): View|RedirectResponse {
// Nursing Services is an ops hub (roster / registry), not a patient specialty shell.
if ($module === 'nursing_services') {
return redirect()->route('care.nursing.services');
}
// Everyone lands on the specialty list/index — never auto-open a patient.
return $this->renderShell($request, $module, 'visits', $modules, $shell, $queueBridge);
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class RosterEntry extends Model
{
use BelongsToOwner, SoftDeletes;
public const STATUS_SCHEDULED = 'scheduled';
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
protected $table = 'care_roster_entries';
protected $fillable = [
'owner_ref',
'organization_id',
'care_unit_id',
'shift_id',
'member_id',
'duty_date',
'status',
'staff_assignment_id',
'notes',
'created_by',
];
protected function casts(): array
{
return [
'duty_date' => 'date',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function careUnit(): BelongsTo
{
return $this->belongsTo(CareUnit::class, 'care_unit_id');
}
public function shift(): BelongsTo
{
return $this->belongsTo(Shift::class, 'shift_id');
}
public function member(): BelongsTo
{
return $this->belongsTo(Member::class, 'member_id');
}
public function staffAssignment(): BelongsTo
{
return $this->belongsTo(StaffAssignment::class, 'staff_assignment_id');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Shift extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_shifts';
protected $fillable = [
'owner_ref',
'organization_id',
'code',
'label',
'start_time',
'end_time',
'color',
'sort_order',
'is_active',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'sort_order' => 'integer',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function rosterEntries(): HasMany
{
return $this->hasMany(RosterEntry::class, 'shift_id');
}
public function timeLabel(): string
{
$start = substr((string) $this->start_time, 0, 5);
$end = substr((string) $this->end_time, 0, 5);
return $start.''.$end;
}
}
+12 -7
View File
@@ -38,7 +38,7 @@ class CarePermissions
'super_admin' => null, // all via admin
'hospital_admin' => null,
'emergency_physician' => ['emergency', 'radiology', 'pathology', 'blood_bank', 'cardiology'],
'ed_nurse' => ['emergency', 'blood_bank', 'radiology', 'pathology'],
'ed_nurse' => ['emergency', 'blood_bank', 'radiology', 'pathology', 'nursing_services'],
'general_physician' => [
'emergency', 'cardiology', 'pediatrics', 'ent', 'dermatology',
'ophthalmology', 'vaccination', 'child_welfare',
@@ -48,7 +48,7 @@ class CarePermissions
'ophthalmology', 'vaccination', 'child_welfare',
], // legacy → GP (desk specialists narrowed elsewhere)
'surgeon' => ['surgery', 'radiology', 'pathology', 'blood_bank'],
'theatre_nurse' => ['surgery', 'blood_bank'],
'theatre_nurse' => ['surgery', 'blood_bank', 'nursing_services'],
'radiologist' => ['radiology'],
'radiographer' => ['radiology'],
'lab_technician' => ['pathology', 'blood_bank'],
@@ -60,11 +60,11 @@ class CarePermissions
'dentist' => ['dentistry'],
'psychiatrist' => ['psychiatry'],
'physiotherapist' => ['physiotherapy'],
'midwife' => ['womens_health'],
'midwife' => ['womens_health', 'nursing_services'],
'obstetrician' => ['womens_health'],
'gynecologist' => ['womens_health'],
'obgyn' => ['womens_health'],
'labour_ward_nurse' => ['womens_health'],
'labour_ward_nurse' => ['womens_health', 'nursing_services'],
'pediatrician' => ['pediatrics'],
'oncologist' => ['oncology', 'infusion'],
'cardiologist' => ['cardiology'],
@@ -75,15 +75,15 @@ class CarePermissions
'podiatrist' => ['podiatry'],
'fertility_specialist' => ['fertility'],
'embryologist' => ['fertility'],
'fertility_nurse' => ['fertility'],
'dialysis_nurse' => ['renal'],
'fertility_nurse' => ['fertility', 'nursing_services'],
'dialysis_nurse' => ['renal', 'nursing_services'],
'ambulance_staff' => ['ambulance', 'emergency'],
'receptionist' => [], // refer only — see roleReferApps
'billing_officer' => [],
'cashier' => [],
'accountant' => [],
'department_manager' => null, // department analytics — all enabled modules view
'nurse' => ['vaccination', 'child_welfare', 'infusion', 'womens_health'],
'nurse' => ['vaccination', 'child_welfare', 'infusion', 'womens_health', 'nursing_services'],
];
/**
@@ -593,6 +593,9 @@ class CarePermissions
$abilities[] = 'nursing.assessments.view';
$abilities[] = 'nursing.assessments.manage';
$abilities[] = 'nursing.performance.view';
$abilities[] = 'nursing.roster.view';
$abilities[] = 'nursing.roster.manage';
$abilities[] = 'nursing.services.view';
}
if (in_array('analytics.department.view', $abilities, true)) {
@@ -602,6 +605,8 @@ class CarePermissions
$abilities[] = 'nursing.handover.view';
$abilities[] = 'nursing.assessments.view';
$abilities[] = 'nursing.performance.view';
$abilities[] = 'nursing.roster.view';
$abilities[] = 'nursing.services.view';
}
return array_values(array_unique($abilities));
@@ -0,0 +1,79 @@
<?php
namespace App\Services\Care;
use App\Models\CareUnit;
use App\Models\Member;
use App\Models\Organization;
use App\Models\StaffAssignment;
use App\Models\User;
use Illuminate\Support\Collection;
class NursingServicesService
{
/** @var list<string> */
public const NURSING_ROLES = [
'nurse',
'ed_nurse',
'theatre_nurse',
'labour_ward_nurse',
'fertility_nurse',
'dialysis_nurse',
'midwife',
'ambulance_staff',
];
/**
* @return Collection<int, Member>
*/
public function nurseRegistry(Organization $organization, string $ownerRef): Collection
{
return Member::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('role', self::NURSING_ROLES)
->with('branch')
->orderBy('role')
->get();
}
/**
* Active placements for nursing staff today.
*
* @return Collection<int, StaffAssignment>
*/
public function activeAllocations(Organization $organization, string $ownerRef): Collection
{
return StaffAssignment::owned($ownerRef)
->where('organization_id', $organization->id)
->where('status', 'active')
->effectiveOn(now())
->whereHas('member', fn ($q) => $q->whereIn('role', self::NURSING_ROLES))
->with(['member', 'careUnit.department', 'department'])
->orderBy('care_unit_id')
->get();
}
/**
* Care units useful for nursing ops (inpatient / outpatient / float / service).
*
* @return Collection<int, CareUnit>
*/
public function careUnits(Organization $organization, string $ownerRef): Collection
{
return CareUnit::owned($ownerRef)
->where('organization_id', $organization->id)
->where('is_active', true)
->with('department.branch')
->orderBy('name')
->get();
}
/**
* @param Collection<int, Member> $members
* @return array<int, string>
*/
public function memberLabels(Collection $members): array
{
return app(RosterService::class)->memberLabels($members);
}
}
+264
View File
@@ -0,0 +1,264 @@
<?php
namespace App\Services\Care;
use App\Models\CareUnit;
use App\Models\Member;
use App\Models\Organization;
use App\Models\RosterEntry;
use App\Models\Shift;
use App\Models\StaffAssignment;
use App\Models\User;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
class RosterService
{
/**
* Default org shifts (seeded once per organization).
*
* @var list<array{code: string, label: string, start_time: string, end_time: string, color: string, sort_order: int}>
*/
protected array $defaults = [
['code' => 'day', 'label' => 'Day shift', 'start_time' => '07:00:00', 'end_time' => '15:00:00', 'color' => '#0ea5e9', 'sort_order' => 10],
['code' => 'evening', 'label' => 'Evening shift', 'start_time' => '15:00:00', 'end_time' => '23:00:00', 'color' => '#f59e0b', 'sort_order' => 20],
['code' => 'night', 'label' => 'Night shift', 'start_time' => '23:00:00', 'end_time' => '07:00:00', 'color' => '#6366f1', 'sort_order' => 30],
];
/**
* @return Collection<int, Shift>
*/
public function ensureDefaultShifts(Organization $organization, string $ownerRef): Collection
{
foreach ($this->defaults as $row) {
Shift::query()->firstOrCreate(
[
'organization_id' => $organization->id,
'code' => $row['code'],
],
[
'owner_ref' => $ownerRef,
'label' => $row['label'],
'start_time' => $row['start_time'],
'end_time' => $row['end_time'],
'color' => $row['color'],
'sort_order' => $row['sort_order'],
'is_active' => true,
],
);
}
return Shift::owned($ownerRef)
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('sort_order')
->orderBy('label')
->get();
}
/**
* Week grid: days × shifts list of roster entries.
*
* @return array{week_start: Carbon, days: list<Carbon>, shifts: Collection, cells: array<string, list<RosterEntry>>, members: Collection}
*/
public function unitWeekGrid(
CareUnit $unit,
string $ownerRef,
?CarbonInterface $weekStart = null,
): array {
$organization = $unit->organization ?? Organization::query()->findOrFail($unit->organization_id);
$shifts = $this->ensureDefaultShifts($organization, $ownerRef);
$start = Carbon::parse($weekStart ?? now())->startOfWeek(Carbon::MONDAY)->startOfDay();
$days = [];
for ($i = 0; $i < 7; $i++) {
$days[] = $start->copy()->addDays($i);
}
$entries = RosterEntry::owned($ownerRef)
->where('care_unit_id', $unit->id)
->whereBetween('duty_date', [$start->toDateString(), $start->copy()->addDays(6)->toDateString()])
->where('status', '!=', RosterEntry::STATUS_CANCELLED)
->with(['member', 'shift'])
->orderBy('id')
->get();
$cells = [];
foreach ($entries as $entry) {
$key = $entry->duty_date->toDateString().'|'.$entry->shift_id;
$cells[$key][] = $entry;
}
$members = Member::owned($ownerRef)
->where('organization_id', $unit->organization_id)
->orderBy('role')
->get();
return [
'week_start' => $start,
'days' => $days,
'shifts' => $shifts,
'cells' => $cells,
'members' => $members,
'member_labels' => $this->memberLabels($members),
];
}
public function assign(
CareUnit $unit,
Shift $shift,
Member $member,
CarbonInterface|string $dutyDate,
string $ownerRef,
?string $actorRef = null,
?string $notes = null,
bool $linkAssignment = true,
): RosterEntry {
if ((int) $shift->organization_id !== (int) $unit->organization_id
|| (int) $member->organization_id !== (int) $unit->organization_id) {
throw ValidationException::withMessages([
'member_id' => 'Staff and shift must belong to the same organization as the care unit.',
]);
}
$date = Carbon::parse($dutyDate)->toDateString();
$existing = RosterEntry::withTrashed()
->where('care_unit_id', $unit->id)
->where('shift_id', $shift->id)
->where('member_id', $member->id)
->whereDate('duty_date', $date)
->first();
if ($existing && ! $existing->trashed() && $existing->status !== RosterEntry::STATUS_CANCELLED) {
throw ValidationException::withMessages([
'member_id' => 'This staff member is already rostered on this shift for that date.',
]);
}
$assignmentId = null;
if ($linkAssignment) {
$assignment = StaffAssignment::create([
'owner_ref' => $ownerRef,
'organization_id' => $unit->organization_id,
'member_id' => $member->id,
'department_id' => $unit->department_id,
'care_unit_id' => $unit->id,
'kind' => 'temporary',
'assignment_role' => null,
'shift_code' => $shift->code,
'starts_on' => $date,
'ends_on' => $date,
'status' => 'active',
'notes' => $notes ?? 'Duty roster',
]);
$assignmentId = $assignment->id;
}
if ($existing) {
if ($existing->trashed()) {
$existing->restore();
}
$existing->update([
'status' => RosterEntry::STATUS_SCHEDULED,
'staff_assignment_id' => $assignmentId,
'notes' => $notes,
'created_by' => $actorRef,
]);
$entry = $existing;
} else {
$entry = RosterEntry::create([
'owner_ref' => $ownerRef,
'organization_id' => $unit->organization_id,
'care_unit_id' => $unit->id,
'shift_id' => $shift->id,
'member_id' => $member->id,
'duty_date' => $date,
'status' => RosterEntry::STATUS_SCHEDULED,
'staff_assignment_id' => $assignmentId,
'notes' => $notes,
'created_by' => $actorRef,
]);
}
AuditLogger::record(
$ownerRef,
'roster.assigned',
$unit->organization_id,
$actorRef,
RosterEntry::class,
$entry->id,
);
return $entry->load(['member', 'shift']);
}
public function cancel(RosterEntry $entry, string $ownerRef, ?string $actorRef = null): void
{
$entryId = $entry->id;
$organizationId = $entry->organization_id;
$entry->update(['status' => RosterEntry::STATUS_CANCELLED]);
if ($entry->staff_assignment_id) {
StaffAssignment::query()->where('id', $entry->staff_assignment_id)->update([
'status' => 'ended',
'ends_on' => $entry->duty_date,
]);
}
// Soft-delete so the unique (unit, shift, member, date) can be reused.
$entry->delete();
AuditLogger::record(
$ownerRef,
'roster.cancelled',
$organizationId,
$actorRef,
RosterEntry::class,
$entryId,
);
}
/**
* Today's allocation across units (or one unit).
*
* @return Collection<int, RosterEntry>
*/
public function todayAllocation(Organization $organization, string $ownerRef, ?int $careUnitId = null): Collection
{
return RosterEntry::owned($ownerRef)
->where('organization_id', $organization->id)
->whereDate('duty_date', now()->toDateString())
->where('status', '!=', RosterEntry::STATUS_CANCELLED)
->when($careUnitId, fn ($q) => $q->where('care_unit_id', $careUnitId))
->with(['member', 'shift', 'careUnit.department'])
->orderBy('care_unit_id')
->orderBy('shift_id')
->get();
}
/**
* @param iterable<Member> $members
* @return array<int, string>
*/
public function memberLabels(iterable $members): array
{
$list = collect($members)->filter()->unique('id')->values();
$refs = $list->pluck('user_ref')->all();
$users = User::query()->whereIn('public_id', $refs)->get()->keyBy('public_id');
$roles = config('care.roles');
$labels = [];
foreach ($list as $member) {
$user = $users->get($member->user_ref);
$display = $user?->name
?? (str_contains((string) $member->user_ref, '@') ? $member->user_ref : ($user?->email ?? $member->user_ref));
$labels[(int) $member->id] = $display.' · '.($roles[$member->role] ?? $member->role);
}
return $labels;
}
}