Files
isaaccladandCursor 9443465c5c
Deploy Ladill Care / deploy (push) Successful in 46s
Lock nursing note shift from duty roster or clock.
Nurses can no longer pick a shift on the form; the server sets it from today's unit roster, falling back to the current clock window.

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

347 lines
12 KiB
PHP
Raw Permalink 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;
}
/**
* 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:0007: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);
}
}