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>
265 lines
9.1 KiB
PHP
265 lines
9.1 KiB
PHP
<?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;
|
||
}
|
||
}
|