Files
ladill-care/app/Services/Care/RosterService.php
T
isaaccladandCursor 7c32b34715
Deploy Ladill Care / deploy (push) Successful in 52s
Gate ward admin nav to nursing roles and add My shifts for staff.
Ambulance and other non-nursing vitals roles no longer see Care units / Nursing Services; staff get a personal duty roster view of assigned shifts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 11:26:52 +00:00

303 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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();
}
/**
* Roster entries for one staff member over a week (or open-ended upcoming window).
*
* @return array{week_start: Carbon, days: list<Carbon>, entries: Collection<int, RosterEntry>, today: Collection<int, RosterEntry>}
*/
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<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;
}
}