Add shift templates, unit duty roster grid, and Nursing Services hub.
Deploy Ladill Care / deploy (push) Successful in 1m0s
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>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Services\Care\CarePermissions;
|
||||
use App\Services\Care\NursingServicesService;
|
||||
use App\Services\Care\RosterService;
|
||||
use App\Services\Care\SpecialtyModuleService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NursingServicesController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(
|
||||
Request $request,
|
||||
NursingServicesService $nursing,
|
||||
RosterService $roster,
|
||||
SpecialtyModuleService $modules,
|
||||
): View {
|
||||
$this->authorizeAbility($request, 'nursing.services.view');
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
$nurses = $nursing->nurseRegistry($organization, $owner);
|
||||
$allocations = $nursing->activeAllocations($organization, $owner);
|
||||
$todayRoster = $roster->todayAllocation($organization, $owner);
|
||||
$units = $nursing->careUnits($organization, $owner);
|
||||
$moduleEnabled = $modules->isEnabled($organization, 'nursing_services');
|
||||
|
||||
$labelMembers = $nurses
|
||||
->merge($allocations->pluck('member')->filter())
|
||||
->merge($todayRoster->pluck('member')->filter())
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$permissions = app(CarePermissions::class);
|
||||
$member = $this->member($request);
|
||||
|
||||
return view('care.nursing.services-hub', [
|
||||
'organization' => $organization,
|
||||
'nurses' => $nurses,
|
||||
'nurseLabels' => $nursing->memberLabels($labelMembers),
|
||||
'allocations' => $allocations,
|
||||
'todayRoster' => $todayRoster,
|
||||
'units' => $units,
|
||||
'moduleEnabled' => $moduleEnabled,
|
||||
'roles' => config('care.roles'),
|
||||
'canRoster' => $permissions->can($member, 'nursing.roster.manage'),
|
||||
'canViewRoster' => $permissions->can($member, 'nursing.roster.view'),
|
||||
'assignmentRoles' => config('care.staff_assignment_roles'),
|
||||
'shiftCodes' => config('care.staff_shift_codes'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Models\CareUnit;
|
||||
use App\Models\Member;
|
||||
use App\Models\RosterEntry;
|
||||
use App\Models\Shift;
|
||||
use App\Services\Care\CarePermissions;
|
||||
use App\Services\Care\RosterService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RosterController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function show(Request $request, CareUnit $careUnit, RosterService $roster): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'nursing.roster.view');
|
||||
$this->authorizeOwner($request, $careUnit);
|
||||
abort_unless((int) $careUnit->organization_id === (int) $this->organization($request)->id, 404);
|
||||
|
||||
$weekStart = $request->filled('week')
|
||||
? Carbon::parse($request->input('week'))->startOfWeek(Carbon::MONDAY)
|
||||
: now()->startOfWeek(Carbon::MONDAY);
|
||||
|
||||
$grid = $roster->unitWeekGrid($careUnit, $this->ownerRef($request), $weekStart);
|
||||
$canManage = app(CarePermissions::class)->can($this->member($request), 'nursing.roster.manage');
|
||||
|
||||
return view('care.nursing.roster', [
|
||||
'unit' => $careUnit->load('department.branch'),
|
||||
'weekStart' => $grid['week_start'],
|
||||
'days' => $grid['days'],
|
||||
'shifts' => $grid['shifts'],
|
||||
'cells' => $grid['cells'],
|
||||
'members' => $grid['members'],
|
||||
'memberLabels' => $grid['member_labels'],
|
||||
'canManage' => $canManage,
|
||||
'statuses' => config('care.roster_entry_statuses'),
|
||||
'prevWeek' => $grid['week_start']->copy()->subWeek()->toDateString(),
|
||||
'nextWeek' => $grid['week_start']->copy()->addWeek()->toDateString(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, CareUnit $careUnit, RosterService $roster): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'nursing.roster.manage');
|
||||
$this->authorizeOwner($request, $careUnit);
|
||||
$organization = $this->organization($request);
|
||||
abort_unless((int) $careUnit->organization_id === (int) $organization->id, 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'shift_id' => ['required', 'integer', 'exists:care_shifts,id'],
|
||||
'member_id' => ['required', 'integer', 'exists:care_members,id'],
|
||||
'duty_date' => ['required', 'date'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$shift = Shift::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->findOrFail($validated['shift_id']);
|
||||
$member = Member::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->findOrFail($validated['member_id']);
|
||||
|
||||
$roster->assign(
|
||||
$careUnit,
|
||||
$shift,
|
||||
$member,
|
||||
$validated['duty_date'],
|
||||
$this->ownerRef($request),
|
||||
$this->actorRef($request),
|
||||
$validated['notes'] ?? null,
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('care.care-units.roster', [
|
||||
'careUnit' => $careUnit,
|
||||
'week' => Carbon::parse($validated['duty_date'])->startOfWeek(Carbon::MONDAY)->toDateString(),
|
||||
])
|
||||
->with('success', 'Staff added to roster.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, CareUnit $careUnit, RosterEntry $rosterEntry, RosterService $roster): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'nursing.roster.manage');
|
||||
$this->authorizeOwner($request, $careUnit);
|
||||
$this->authorizeOwner($request, $rosterEntry);
|
||||
abort_unless((int) $rosterEntry->care_unit_id === (int) $careUnit->id, 404);
|
||||
|
||||
$week = $rosterEntry->duty_date->copy()->startOfWeek(Carbon::MONDAY)->toDateString();
|
||||
$roster->cancel($rosterEntry, $this->ownerRef($request), $this->actorRef($request));
|
||||
|
||||
return redirect()
|
||||
->route('care.care-units.roster', ['careUnit' => $careUnit, 'week' => $week])
|
||||
->with('success', 'Roster entry cancelled.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Models\Shift;
|
||||
use App\Services\Care\AuditLogger;
|
||||
use App\Services\Care\RosterService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ShiftController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request, RosterService $roster): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'nursing.roster.manage');
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
$roster->ensureDefaultShifts($organization, $owner);
|
||||
|
||||
$shifts = Shift::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('label')
|
||||
->get();
|
||||
|
||||
return view('care.nursing.shifts-index', [
|
||||
'shifts' => $shifts,
|
||||
'organization' => $organization,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'nursing.roster.manage');
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'code' => [
|
||||
'required', 'string', 'max:50', 'alpha_dash',
|
||||
Rule::unique('care_shifts', 'code')->where(fn ($q) => $q->where('organization_id', $organization->id)->whereNull('deleted_at')),
|
||||
],
|
||||
'label' => ['required', 'string', 'max:100'],
|
||||
'start_time' => ['required', 'date_format:H:i'],
|
||||
'end_time' => ['required', 'date_format:H:i'],
|
||||
'color' => ['nullable', 'string', 'max:20'],
|
||||
]);
|
||||
|
||||
$shift = Shift::create([
|
||||
'owner_ref' => $owner,
|
||||
'organization_id' => $organization->id,
|
||||
'code' => strtolower($validated['code']),
|
||||
'label' => $validated['label'],
|
||||
'start_time' => $validated['start_time'].':00',
|
||||
'end_time' => $validated['end_time'].':00',
|
||||
'color' => $validated['color'] ?? '#64748b',
|
||||
'sort_order' => ((int) Shift::query()->where('organization_id', $organization->id)->max('sort_order')) + 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
AuditLogger::record($owner, 'shift.created', $organization->id, $owner, Shift::class, $shift->id);
|
||||
|
||||
return redirect()->route('care.shifts.index')->with('success', 'Shift created.');
|
||||
}
|
||||
|
||||
public function update(Request $request, Shift $shift): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'nursing.roster.manage');
|
||||
$this->authorizeOwner($request, $shift);
|
||||
|
||||
$validated = $request->validate([
|
||||
'label' => ['required', 'string', 'max:100'],
|
||||
'start_time' => ['required', 'date_format:H:i'],
|
||||
'end_time' => ['required', 'date_format:H:i'],
|
||||
'color' => ['nullable', 'string', 'max:20'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$shift->update([
|
||||
'label' => $validated['label'],
|
||||
'start_time' => $validated['start_time'].':00',
|
||||
'end_time' => $validated['end_time'].':00',
|
||||
'color' => $validated['color'] ?? $shift->color,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
AuditLogger::record($this->ownerRef($request), 'shift.updated', $shift->organization_id, $this->ownerRef($request), Shift::class, $shift->id);
|
||||
|
||||
return redirect()->route('care.shifts.index')->with('success', 'Shift updated.');
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,12 @@ class SpecialtyModuleController extends Controller
|
||||
SpecialtyModuleService $modules,
|
||||
SpecialtyShellService $shell,
|
||||
CareQueueBridge $queueBridge,
|
||||
): View {
|
||||
): View|RedirectResponse {
|
||||
// Nursing Services is an ops hub (roster / registry), not a patient specialty shell.
|
||||
if ($module === 'nursing_services') {
|
||||
return redirect()->route('care.nursing.services');
|
||||
}
|
||||
|
||||
// Everyone lands on the specialty list/index — never auto-open a patient.
|
||||
return $this->renderShell($request, $module, 'visits', $modules, $shell, $queueBridge);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class RosterEntry extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
public const STATUS_SCHEDULED = 'scheduled';
|
||||
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $table = 'care_roster_entries';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref',
|
||||
'organization_id',
|
||||
'care_unit_id',
|
||||
'shift_id',
|
||||
'member_id',
|
||||
'duty_date',
|
||||
'status',
|
||||
'staff_assignment_id',
|
||||
'notes',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'duty_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function careUnit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CareUnit::class, 'care_unit_id');
|
||||
}
|
||||
|
||||
public function shift(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Shift::class, 'shift_id');
|
||||
}
|
||||
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class, 'member_id');
|
||||
}
|
||||
|
||||
public function staffAssignment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StaffAssignment::class, 'staff_assignment_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Shift extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
protected $table = 'care_shifts';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref',
|
||||
'organization_id',
|
||||
'code',
|
||||
'label',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'color',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function rosterEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(RosterEntry::class, 'shift_id');
|
||||
}
|
||||
|
||||
public function timeLabel(): string
|
||||
{
|
||||
$start = substr((string) $this->start_time, 0, 5);
|
||||
$end = substr((string) $this->end_time, 0, 5);
|
||||
|
||||
return $start.'–'.$end;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class CarePermissions
|
||||
'super_admin' => null, // all via admin
|
||||
'hospital_admin' => null,
|
||||
'emergency_physician' => ['emergency', 'radiology', 'pathology', 'blood_bank', 'cardiology'],
|
||||
'ed_nurse' => ['emergency', 'blood_bank', 'radiology', 'pathology'],
|
||||
'ed_nurse' => ['emergency', 'blood_bank', 'radiology', 'pathology', 'nursing_services'],
|
||||
'general_physician' => [
|
||||
'emergency', 'cardiology', 'pediatrics', 'ent', 'dermatology',
|
||||
'ophthalmology', 'vaccination', 'child_welfare',
|
||||
@@ -48,7 +48,7 @@ class CarePermissions
|
||||
'ophthalmology', 'vaccination', 'child_welfare',
|
||||
], // legacy → GP (desk specialists narrowed elsewhere)
|
||||
'surgeon' => ['surgery', 'radiology', 'pathology', 'blood_bank'],
|
||||
'theatre_nurse' => ['surgery', 'blood_bank'],
|
||||
'theatre_nurse' => ['surgery', 'blood_bank', 'nursing_services'],
|
||||
'radiologist' => ['radiology'],
|
||||
'radiographer' => ['radiology'],
|
||||
'lab_technician' => ['pathology', 'blood_bank'],
|
||||
@@ -60,11 +60,11 @@ class CarePermissions
|
||||
'dentist' => ['dentistry'],
|
||||
'psychiatrist' => ['psychiatry'],
|
||||
'physiotherapist' => ['physiotherapy'],
|
||||
'midwife' => ['womens_health'],
|
||||
'midwife' => ['womens_health', 'nursing_services'],
|
||||
'obstetrician' => ['womens_health'],
|
||||
'gynecologist' => ['womens_health'],
|
||||
'obgyn' => ['womens_health'],
|
||||
'labour_ward_nurse' => ['womens_health'],
|
||||
'labour_ward_nurse' => ['womens_health', 'nursing_services'],
|
||||
'pediatrician' => ['pediatrics'],
|
||||
'oncologist' => ['oncology', 'infusion'],
|
||||
'cardiologist' => ['cardiology'],
|
||||
@@ -75,15 +75,15 @@ class CarePermissions
|
||||
'podiatrist' => ['podiatry'],
|
||||
'fertility_specialist' => ['fertility'],
|
||||
'embryologist' => ['fertility'],
|
||||
'fertility_nurse' => ['fertility'],
|
||||
'dialysis_nurse' => ['renal'],
|
||||
'fertility_nurse' => ['fertility', 'nursing_services'],
|
||||
'dialysis_nurse' => ['renal', 'nursing_services'],
|
||||
'ambulance_staff' => ['ambulance', 'emergency'],
|
||||
'receptionist' => [], // refer only — see roleReferApps
|
||||
'billing_officer' => [],
|
||||
'cashier' => [],
|
||||
'accountant' => [],
|
||||
'department_manager' => null, // department analytics — all enabled modules view
|
||||
'nurse' => ['vaccination', 'child_welfare', 'infusion', 'womens_health'],
|
||||
'nurse' => ['vaccination', 'child_welfare', 'infusion', 'womens_health', 'nursing_services'],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -593,6 +593,9 @@ class CarePermissions
|
||||
$abilities[] = 'nursing.assessments.view';
|
||||
$abilities[] = 'nursing.assessments.manage';
|
||||
$abilities[] = 'nursing.performance.view';
|
||||
$abilities[] = 'nursing.roster.view';
|
||||
$abilities[] = 'nursing.roster.manage';
|
||||
$abilities[] = 'nursing.services.view';
|
||||
}
|
||||
|
||||
if (in_array('analytics.department.view', $abilities, true)) {
|
||||
@@ -602,6 +605,8 @@ class CarePermissions
|
||||
$abilities[] = 'nursing.handover.view';
|
||||
$abilities[] = 'nursing.assessments.view';
|
||||
$abilities[] = 'nursing.performance.view';
|
||||
$abilities[] = 'nursing.roster.view';
|
||||
$abilities[] = 'nursing.services.view';
|
||||
}
|
||||
|
||||
return array_values(array_unique($abilities));
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\CareUnit;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class NursingServicesService
|
||||
{
|
||||
/** @var list<string> */
|
||||
public const NURSING_ROLES = [
|
||||
'nurse',
|
||||
'ed_nurse',
|
||||
'theatre_nurse',
|
||||
'labour_ward_nurse',
|
||||
'fertility_nurse',
|
||||
'dialysis_nurse',
|
||||
'midwife',
|
||||
'ambulance_staff',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return Collection<int, Member>
|
||||
*/
|
||||
public function nurseRegistry(Organization $organization, string $ownerRef): Collection
|
||||
{
|
||||
return Member::owned($ownerRef)
|
||||
->where('organization_id', $organization->id)
|
||||
->whereIn('role', self::NURSING_ROLES)
|
||||
->with('branch')
|
||||
->orderBy('role')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Active placements for nursing staff today.
|
||||
*
|
||||
* @return Collection<int, StaffAssignment>
|
||||
*/
|
||||
public function activeAllocations(Organization $organization, string $ownerRef): Collection
|
||||
{
|
||||
return StaffAssignment::owned($ownerRef)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('status', 'active')
|
||||
->effectiveOn(now())
|
||||
->whereHas('member', fn ($q) => $q->whereIn('role', self::NURSING_ROLES))
|
||||
->with(['member', 'careUnit.department', 'department'])
|
||||
->orderBy('care_unit_id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Care units useful for nursing ops (inpatient / outpatient / float / service).
|
||||
*
|
||||
* @return Collection<int, CareUnit>
|
||||
*/
|
||||
public function careUnits(Organization $organization, string $ownerRef): Collection
|
||||
{
|
||||
return CareUnit::owned($ownerRef)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->with('department.branch')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Member> $members
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function memberLabels(Collection $members): array
|
||||
{
|
||||
return app(RosterService::class)->memberLabels($members);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user