*/ 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 */ 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, shifts: Collection, cells: array>, 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, ): 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.', ]); } if ($existing) { if ($existing->trashed()) { $existing->restore(); } $existing->update([ 'status' => RosterEntry::STATUS_SCHEDULED, 'staff_assignment_id' => null, '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' => null, '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]); // 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 */ 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(); } /** * Roster entries for one staff member over a week (or open-ended upcoming window). * * @return array{week_start: Carbon, days: list, entries: Collection, today: Collection} */ public function memberWeek( Member $member, string $ownerRef, ?CarbonInterface $weekStart = null, ): array { $start = Carbon::parse($weekStart ?? now())->startOfWeek(Carbon::MONDAY)->startOfDay(); $end = $start->copy()->addDays(6); $days = []; for ($i = 0; $i < 7; $i++) { $days[] = $start->copy()->addDays($i); } $entries = RosterEntry::owned($ownerRef) ->where('member_id', $member->id) ->whereBetween('duty_date', [$start->toDateString(), $end->toDateString()]) ->where('status', '!=', RosterEntry::STATUS_CANCELLED) ->with(['shift', 'careUnit.department']) ->orderBy('duty_date') ->orderBy('shift_id') ->get(); $today = $entries->filter( fn (RosterEntry $entry) => $entry->duty_date->isSameDay(now()) )->values(); return [ 'week_start' => $start, 'days' => $days, 'entries' => $entries, 'today' => $today, ]; } /** * @param iterable $members * @return array */ 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; } /** * Shift code for nursing notes: today's roster on this unit, else clock-based default. */ public function dutyShiftCodeForMember( CareUnit $unit, ?Member $member, string $ownerRef, ?CarbonInterface $at = null, ): ?string { $at = Carbon::parse($at ?? now()); $allowed = array_keys(config('care.staff_shift_codes', [])); if ($member) { $entry = RosterEntry::owned($ownerRef) ->where('care_unit_id', $unit->id) ->where('member_id', $member->id) ->whereDate('duty_date', $at->toDateString()) ->where('status', '!=', RosterEntry::STATUS_CANCELLED) ->with('shift') ->orderBy('shift_id') ->first(); $code = $entry?->shift?->code; if ($code && in_array($code, $allowed, true)) { return $code; } } $organization = $unit->organization ?? Organization::query()->find($unit->organization_id); if (! $organization) { return null; } $shifts = $this->ensureDefaultShifts($organization, $ownerRef); foreach ($shifts as $shift) { if (! in_array($shift->code, $allowed, true)) { continue; } if ($this->clockFallsInShift($at, (string) $shift->start_time, (string) $shift->end_time)) { return $shift->code; } } return null; } protected function clockFallsInShift(CarbonInterface $at, string $startTime, string $endTime): bool { $minutes = ((int) $at->format('H') * 60) + (int) $at->format('i'); $start = $this->timeToMinutes($startTime); $end = $this->timeToMinutes($endTime); if ($start === $end) { return true; } // Overnight (e.g. 23:00–07:00) if ($start > $end) { return $minutes >= $start || $minutes < $end; } return $minutes >= $start && $minutes < $end; } protected function timeToMinutes(string $time): int { $parts = explode(':', substr($time, 0, 5)); return ((int) ($parts[0] ?? 0) * 60) + (int) ($parts[1] ?? 0); } }