Files
ladill-care/app/Services/Care/RosterService.php
T
isaaccladandCursor b7aca6ee2b
Deploy Ladill Care / deploy (push) Successful in 30s
Separate unit placements from shift duty assignments.
Roster no longer writes temporary staff assignments; unit assignment UI drops shift fields and labels clarify the two workflows.

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

275 lines
9.4 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\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,
): 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<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;
}
}