From b2cebe29085e012622ed96c9fdc86fa9d9b12adb Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 20 Jul 2026 10:40:25 +0000 Subject: [PATCH] Add shift templates, unit duty roster grid, and Nursing Services hub. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Care/NursingServicesController.php | 58 ++++ .../Controllers/Care/RosterController.php | 103 +++++++ app/Http/Controllers/Care/ShiftController.php | 98 +++++++ .../Care/SpecialtyModuleController.php | 7 +- app/Models/RosterEntry.php | 68 +++++ app/Models/Shift.php | 54 ++++ app/Services/Care/CarePermissions.php | 19 +- app/Services/Care/NursingServicesService.php | 79 ++++++ app/Services/Care/RosterService.php | 264 ++++++++++++++++++ config/care.php | 7 + config/care_specialty_modules.php | 24 ++ config/care_specialty_shell.php | 15 + ...0_180000_create_care_shifts_and_roster.php | 61 ++++ .../care/admin/care-units/show.blade.php | 4 + resources/views/care/nursing/roster.blade.php | 124 ++++++++ .../views/care/nursing/services-hub.blade.php | 135 +++++++++ .../views/care/nursing/shifts-index.blade.php | 110 ++++++++ resources/views/partials/sidebar.blade.php | 4 + routes/web.php | 13 + .../CareRosterAndNursingServicesTest.php | 200 +++++++++++++ 20 files changed, 1439 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/Care/NursingServicesController.php create mode 100644 app/Http/Controllers/Care/RosterController.php create mode 100644 app/Http/Controllers/Care/ShiftController.php create mode 100644 app/Models/RosterEntry.php create mode 100644 app/Models/Shift.php create mode 100644 app/Services/Care/NursingServicesService.php create mode 100644 app/Services/Care/RosterService.php create mode 100644 database/migrations/2026_07_20_180000_create_care_shifts_and_roster.php create mode 100644 resources/views/care/nursing/roster.blade.php create mode 100644 resources/views/care/nursing/services-hub.blade.php create mode 100644 resources/views/care/nursing/shifts-index.blade.php create mode 100644 tests/Feature/CareRosterAndNursingServicesTest.php diff --git a/app/Http/Controllers/Care/NursingServicesController.php b/app/Http/Controllers/Care/NursingServicesController.php new file mode 100644 index 0000000..2a2e4f1 --- /dev/null +++ b/app/Http/Controllers/Care/NursingServicesController.php @@ -0,0 +1,58 @@ +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'), + ]); + } +} diff --git a/app/Http/Controllers/Care/RosterController.php b/app/Http/Controllers/Care/RosterController.php new file mode 100644 index 0000000..e651ca5 --- /dev/null +++ b/app/Http/Controllers/Care/RosterController.php @@ -0,0 +1,103 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Care/ShiftController.php b/app/Http/Controllers/Care/ShiftController.php new file mode 100644 index 0000000..04367ca --- /dev/null +++ b/app/Http/Controllers/Care/ShiftController.php @@ -0,0 +1,98 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 131d7fe..2da08fc 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -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); } diff --git a/app/Models/RosterEntry.php b/app/Models/RosterEntry.php new file mode 100644 index 0000000..7daf5ac --- /dev/null +++ b/app/Models/RosterEntry.php @@ -0,0 +1,68 @@ + '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'); + } +} diff --git a/app/Models/Shift.php b/app/Models/Shift.php new file mode 100644 index 0000000..5ba5c03 --- /dev/null +++ b/app/Models/Shift.php @@ -0,0 +1,54 @@ + '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; + } +} diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index d64363b..01ccbbd 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -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)); diff --git a/app/Services/Care/NursingServicesService.php b/app/Services/Care/NursingServicesService.php new file mode 100644 index 0000000..281e1e9 --- /dev/null +++ b/app/Services/Care/NursingServicesService.php @@ -0,0 +1,79 @@ + */ + public const NURSING_ROLES = [ + 'nurse', + 'ed_nurse', + 'theatre_nurse', + 'labour_ward_nurse', + 'fertility_nurse', + 'dialysis_nurse', + 'midwife', + 'ambulance_staff', + ]; + + /** + * @return Collection + */ + 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 + */ + 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 + */ + 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 $members + * @return array + */ + public function memberLabels(Collection $members): array + { + return app(RosterService::class)->memberLabels($members); + } +} diff --git a/app/Services/Care/RosterService.php b/app/Services/Care/RosterService.php new file mode 100644 index 0000000..a9f55c6 --- /dev/null +++ b/app/Services/Care/RosterService.php @@ -0,0 +1,264 @@ + + */ + 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 + */ + 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, shifts: Collection, cells: array>, 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 + */ + 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 $members + * @return array + */ + 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; + } +} diff --git a/config/care.php b/config/care.php index a4ff032..0c7d40d 100644 --- a/config/care.php +++ b/config/care.php @@ -143,6 +143,13 @@ return [ 'rotating' => 'Rotating', ], + 'roster_entry_statuses' => [ + 'scheduled' => 'Scheduled', + 'confirmed' => 'Confirmed', + 'completed' => 'Completed', + 'cancelled' => 'Cancelled', + ], + 'mar_frequency_codes' => [ 'od' => 'Once daily (OD)', 'bd' => 'Twice daily (BD)', diff --git a/config/care_specialty_modules.php b/config/care_specialty_modules.php index eef5867..6436154 100644 --- a/config/care_specialty_modules.php +++ b/config/care_specialty_modules.php @@ -495,4 +495,28 @@ return [ ], 'specialist_keywords' => ['ambulance', 'ems', 'paramedic', 'prehospital'], ], + 'nursing_services' => [ + 'label' => 'Nursing Services', + 'description' => 'Nursing administration hub: nurse registry, ward allocation, duty roster, and unit clinical shortcuts.', + 'department_type' => 'nursing', + 'department_name' => 'Nursing Services', + 'queue_name' => 'Nursing', + 'queue_prefix' => 'NUR', + 'nav_label' => 'Nursing', + 'icon' => 'user-group', + 'queue_keywords' => ['nursing', 'nurse', 'matron', 'ward'], + 'access' => 'general', + 'roles' => [ + 'nurse', 'ed_nurse', 'theatre_nurse', 'labour_ward_nurse', + 'fertility_nurse', 'dialysis_nurse', 'midwife', + ], + 'view_roles' => [ + 'nurse', 'ed_nurse', 'theatre_nurse', 'labour_ward_nurse', + 'fertility_nurse', 'dialysis_nurse', 'midwife', + 'department_manager', 'general_physician', 'doctor', + ], + 'refer_roles' => [], + 'specialist_keywords' => ['nursing services', 'nursing', 'matron'], + 'default_on_paid_plans' => true, + ], ]; diff --git a/config/care_specialty_shell.php b/config/care_specialty_shell.php index 41c76dd..0cea443 100644 --- a/config/care_specialty_shell.php +++ b/config/care_specialty_shell.php @@ -910,5 +910,20 @@ return [ ], ], + // Ops hub — patient shell is unused (specialty.show redirects to nursing hub). + 'nursing_services' => [ + 'stages' => [ + ['code' => 'active', 'label' => 'On duty', 'queue_point' => null], + ], + 'services' => [], + 'workspace_tabs' => [ + 'overview' => 'Overview', + ], + 'stage_tabs' => [ + 'active' => 'overview', + ], + 'actions' => [], + ], + ], ]; diff --git a/database/migrations/2026_07_20_180000_create_care_shifts_and_roster.php b/database/migrations/2026_07_20_180000_create_care_shifts_and_roster.php new file mode 100644 index 0000000..2e6b208 --- /dev/null +++ b/database/migrations/2026_07_20_180000_create_care_shifts_and_roster.php @@ -0,0 +1,61 @@ +id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->string('code'); // day, evening, night, custom… + $table->string('label'); + $table->time('start_time'); + $table->time('end_time'); + $table->string('color')->nullable(); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['organization_id', 'code']); + $table->index(['organization_id', 'is_active']); + }); + + Schema::create('care_roster_entries', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('care_unit_id')->constrained('care_care_units')->cascadeOnDelete(); + $table->foreignId('shift_id')->constrained('care_shifts')->cascadeOnDelete(); + $table->foreignId('member_id')->constrained('care_members')->cascadeOnDelete(); + $table->date('duty_date'); + /** scheduled | confirmed | completed | cancelled */ + $table->string('status')->default('scheduled'); + $table->foreignId('staff_assignment_id')->nullable() + ->constrained('care_staff_assignments')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->string('created_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['care_unit_id', 'shift_id', 'member_id', 'duty_date'], 'care_roster_unit_shift_member_date'); + $table->index(['care_unit_id', 'duty_date']); + $table->index(['member_id', 'duty_date']); + $table->index(['organization_id', 'duty_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_roster_entries'); + Schema::dropIfExists('care_shifts'); + } +}; diff --git a/resources/views/care/admin/care-units/show.blade.php b/resources/views/care/admin/care-units/show.blade.php index 01b6060..3c5b8c4 100644 --- a/resources/views/care/admin/care-units/show.blade.php +++ b/resources/views/care/admin/care-units/show.blade.php @@ -29,10 +29,14 @@ $canHandover = $permissions->can($member, 'nursing.handover.view'); $canAssess = $permissions->can($member, 'nursing.assessments.view'); $canPerf = $permissions->can($member, 'nursing.performance.view'); + $canRoster = $permissions->can($member, 'nursing.roster.view'); @endphp @if ($canMar) MAR board @endif + @if ($canRoster) + Duty roster + @endif @if ($canAssess) Assessments @endif diff --git a/resources/views/care/nursing/roster.blade.php b/resources/views/care/nursing/roster.blade.php new file mode 100644 index 0000000..1dc1e66 --- /dev/null +++ b/resources/views/care/nursing/roster.blade.php @@ -0,0 +1,124 @@ + +
+
+
+

+ {{ $unit->name }} + / + Duty roster +

+

Week roster

+

+ {{ $weekStart->format('d M Y') }} – {{ $weekStart->copy()->addDays(6)->format('d M Y') }} + · {{ $unit->department?->name }} +

+
+
+ Previous + This week + Next + @if ($canManage) + Shift templates + @endif +
+
+ +
+ + + + + @foreach ($days as $day) + + @endforeach + + + + @foreach ($shifts as $shift) + + + @foreach ($days as $day) + @php + $key = $day->toDateString().'|'.$shift->id; + $entries = $cells[$key] ?? []; + @endphp + + @endforeach + + @endforeach + +
Shift +
{{ $day->format('D') }}
+
{{ $day->format('d M') }}
+
+
+ + {{ $shift->label }} +
+
{{ $shift->timeLabel() }}
+
+
+ @forelse ($entries as $entry) +
+
+ {{ $memberLabels[$entry->member_id] ?? ($entry->member?->user_ref ?? 'Staff') }} +
+ @if ($canManage) +
+ @csrf + @method('DELETE') + +
+ @endif +
+ @empty +

+ @endforelse +
+
+
+ + @if ($canManage) +
+

Add to roster

+

Creates a temporary staff assignment for that duty date.

+
+ @csrf +
+ + + @error('duty_date')

{{ $message }}

@enderror +
+
+ + + @error('shift_id')

{{ $message }}

@enderror +
+
+ + + @error('member_id')

{{ $message }}

@enderror +
+
+ +
+
+ + +
+
+
+ @endif +
+
diff --git a/resources/views/care/nursing/services-hub.blade.php b/resources/views/care/nursing/services-hub.blade.php new file mode 100644 index 0000000..ce39bb2 --- /dev/null +++ b/resources/views/care/nursing/services-hub.blade.php @@ -0,0 +1,135 @@ + +
+
+
+

Nursing Services

+

+ Nurse registry, today’s duty allocation, and shortcuts into ward boards. + @unless ($moduleEnabled) + Module not enabled for this organization — enable under Settings → Modules. + @endunless +

+
+
+ @if ($canRoster) + Shift templates + @endif + Care units + Staff assignments +
+
+ +
+
+

Nurse registry

+

{{ $nurses->count() }}

+
    + @forelse ($nurses as $nurse) +
  • + {{ $nurseLabels[$nurse->id] ?? $nurse->user_ref }} +
  • + @empty +
  • No nursing-role members yet.
  • + @endforelse +
+
+ +
+

Today’s roster

+
+ + + + + + + + + + @forelse ($todayRoster as $entry) + + + + + + @empty + + @endforelse + +
UnitShiftStaff
+ @if ($canViewRoster) + + {{ $entry->careUnit?->name ?? '—' }} + + @else + {{ $entry->careUnit?->name ?? '—' }} + @endif + + + {{ $entry->shift?->label ?? '—' }} + {{ $nurseLabels[$entry->member_id] ?? ($entry->member?->user_ref ?? '—') }}
No duty roster entries for today.
+
+
+
+ +
+

Active allocations

+

Dated staff assignments effective today (primary, temporary, specialty).

+
+ + + + + + + + + + + @forelse ($allocations as $assignment) + + + + + + + @empty + + @endforelse + +
StaffUnit / departmentKindDates
+ {{ $nurseLabels[$assignment->member_id] ?? ($assignment->member?->user_ref ?? '—') }} + + {{ $assignment->careUnit?->name ?? $assignment->department?->name ?? '—' }} + {{ $assignment->kind }} + {{ $assignment->starts_on?->format('d M Y') }} + – + {{ $assignment->ends_on?->format('d M Y') ?? 'open' }} +
No active nursing allocations today.
+
+
+ +
+

Care units

+
+ @forelse ($units as $unit) +
+ {{ $unit->name }} +

{{ $unit->department?->name }} · {{ str_replace('_', ' ', $unit->kind) }}

+
+ @if ($canViewRoster) + Roster + @endif + MAR + Notes + Handover + Assess +
+
+ @empty +

No care units yet — create units under Care units.

+ @endforelse +
+
+
+
diff --git a/resources/views/care/nursing/shifts-index.blade.php b/resources/views/care/nursing/shifts-index.blade.php new file mode 100644 index 0000000..68366dc --- /dev/null +++ b/resources/views/care/nursing/shifts-index.blade.php @@ -0,0 +1,110 @@ + +
+
+
+

+ Nursing Services + / + Shifts +

+

Shift templates

+

Organization-wide duty periods used on unit rosters.

+
+
+ +
+ + + + + + + + + + + + @foreach ($shifts as $shift) + + + + + + + + @endforeach + +
LabelCodeHoursStatus
+ + {{ $shift->label }} + {{ $shift->code }}{{ $shift->timeLabel() }} + @if ($shift->is_active) + Active + @else + Inactive + @endif + +
+ Edit +
+ @csrf + @method('PUT') +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+
+
+
+ +
+

Add shift

+
+ @csrf +
+ + + @error('code')

{{ $message }}

@enderror +
+
+ + + @error('label')

{{ $message }}

@enderror +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index a108a14..30db8a8 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -119,6 +119,10 @@ $adminNav[] = ['name' => 'Care units', 'route' => route('care.care-units.index'), 'active' => request()->routeIs('care.care-units.*'), 'icon' => '']; } + if ($permissions->can($member, 'nursing.services.view')) { + $adminNav[] = ['name' => 'Nursing Services', 'route' => route('care.nursing.services'), 'active' => request()->routeIs('care.nursing.*') || request()->routeIs('care.shifts.*'), + 'icon' => '']; + } if ($permissions->can($member, 'admin.members.view')) { $adminNav[] = ['name' => 'Staff assignments', 'route' => route('care.staff-assignments.index'), 'active' => request()->routeIs('care.staff-assignments.*'), 'icon' => '']; diff --git a/routes/web.php b/routes/web.php index a198625..88c42e6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -20,6 +20,9 @@ use App\Http\Controllers\Care\MarController; use App\Http\Controllers\Care\NursingDocumentationController; use App\Http\Controllers\Care\NursingAssessmentController; use App\Http\Controllers\Care\NursePerformanceController; +use App\Http\Controllers\Care\ShiftController; +use App\Http\Controllers\Care\RosterController; +use App\Http\Controllers\Care\NursingServicesController; use App\Http\Controllers\Care\DeviceController; use App\Http\Controllers\Care\DisplayPublicController; use App\Http\Controllers\Care\DisplayScreenController; @@ -454,6 +457,16 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/care-units/{careUnit}/visits/{visit}/vitals', [NursingAssessmentController::class, 'storeVitals'])->name('care.care-units.vitals.store'); Route::get('/care-units/{careUnit}/performance', [NursePerformanceController::class, 'show'])->name('care.care-units.performance'); + Route::get('/care-units/{careUnit}/roster', [RosterController::class, 'show'])->name('care.care-units.roster'); + Route::post('/care-units/{careUnit}/roster', [RosterController::class, 'store'])->name('care.care-units.roster.store'); + Route::delete('/care-units/{careUnit}/roster/{rosterEntry}', [RosterController::class, 'destroy'])->name('care.care-units.roster.destroy'); + + Route::get('/shifts', [ShiftController::class, 'index'])->name('care.shifts.index'); + Route::post('/shifts', [ShiftController::class, 'store'])->name('care.shifts.store'); + Route::put('/shifts/{shift}', [ShiftController::class, 'update'])->name('care.shifts.update'); + + Route::get('/nursing/services', [NursingServicesController::class, 'index'])->name('care.nursing.services'); + Route::get('/staff-assignments', [StaffAssignmentController::class, 'index'])->name('care.staff-assignments.index'); Route::get('/staff-assignments/create', [StaffAssignmentController::class, 'create'])->name('care.staff-assignments.create'); Route::post('/staff-assignments', [StaffAssignmentController::class, 'store'])->name('care.staff-assignments.store'); diff --git a/tests/Feature/CareRosterAndNursingServicesTest.php b/tests/Feature/CareRosterAndNursingServicesTest.php new file mode 100644 index 0000000..0ee08e1 --- /dev/null +++ b/tests/Feature/CareRosterAndNursingServicesTest.php @@ -0,0 +1,200 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'roster-owner', + 'name' => 'Owner', + 'email' => 'roster-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Roster Hospital', + 'slug' => 'roster-hospital', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'hospital', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'hospital_admin', + ]); + + $branch = Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $department = Department::create([ + 'owner_ref' => $this->owner->public_id, + 'branch_id' => $branch->id, + 'name' => 'Medicine', + 'type' => 'general', + 'is_active' => true, + ]); + + $this->unit = CareUnit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'department_id' => $department->id, + 'name' => 'Medical Ward', + 'kind' => 'inpatient', + 'is_active' => true, + ]); + + $nurseUser = User::create([ + 'public_id' => 'roster-nurse', + 'name' => 'Nurse Ama', + 'email' => 'roster-nurse@example.com', + ]); + + $this->nurseMember = Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $nurseUser->public_id, + 'role' => 'nurse', + 'branch_id' => $branch->id, + ]); + } + + public function test_default_shifts_are_seeded_on_index(): void + { + $this->actingAs($this->owner) + ->get(route('care.shifts.index')) + ->assertOk() + ->assertSee('Day shift') + ->assertSee('Evening shift') + ->assertSee('Night shift'); + + $this->assertSame(3, Shift::query()->where('organization_id', $this->organization->id)->count()); + } + + public function test_roster_assign_links_temporary_staff_assignment(): void + { + $roster = app(RosterService::class); + $shifts = $roster->ensureDefaultShifts($this->organization, $this->owner->public_id); + $day = $shifts->firstWhere('code', 'day'); + + $this->actingAs($this->owner) + ->post(route('care.care-units.roster.store', $this->unit), [ + 'shift_id' => $day->id, + 'member_id' => $this->nurseMember->id, + 'duty_date' => now()->toDateString(), + 'notes' => 'Cover', + ]) + ->assertRedirect(); + + $entry = RosterEntry::query()->first(); + $this->assertNotNull($entry); + $this->assertSame(RosterEntry::STATUS_SCHEDULED, $entry->status); + $this->assertNotNull($entry->staff_assignment_id); + + $assignment = StaffAssignment::query()->find($entry->staff_assignment_id); + $this->assertSame('temporary', $assignment->kind); + $this->assertSame('day', $assignment->shift_code); + $this->assertSame((int) $this->unit->id, (int) $assignment->care_unit_id); + } + + public function test_roster_cancel_ends_assignment_and_allows_reassign(): void + { + $roster = app(RosterService::class); + $shifts = $roster->ensureDefaultShifts($this->organization, $this->owner->public_id); + $day = $shifts->firstWhere('code', 'day'); + $date = now()->toDateString(); + + $entry = $roster->assign( + $this->unit, + $day, + $this->nurseMember, + $date, + $this->owner->public_id, + $this->owner->public_id, + ); + + $this->actingAs($this->owner) + ->delete(route('care.care-units.roster.destroy', [$this->unit, $entry])) + ->assertRedirect(); + + $this->assertSoftDeleted('care_roster_entries', ['id' => $entry->id]); + $this->assertDatabaseHas('care_staff_assignments', [ + 'id' => $entry->staff_assignment_id, + 'status' => 'ended', + ]); + + $this->actingAs($this->owner) + ->post(route('care.care-units.roster.store', $this->unit), [ + 'shift_id' => $day->id, + 'member_id' => $this->nurseMember->id, + 'duty_date' => $date, + ]) + ->assertRedirect(); + + $this->assertSame(1, RosterEntry::query()->where('care_unit_id', $this->unit->id)->count()); + } + + public function test_nursing_services_hub_and_specialty_redirect(): void + { + $this->assertTrue(app(SpecialtyModuleService::class)->isEnabled($this->organization, 'nursing_services')); + + $this->actingAs($this->owner) + ->get(route('care.nursing.services')) + ->assertOk() + ->assertSee('Nursing Services') + ->assertSee('Nurse Ama') + ->assertSee('Medical Ward'); + + $this->actingAs($this->owner) + ->get(route('care.specialty.show', 'nursing_services')) + ->assertRedirect(route('care.nursing.services')); + } + + public function test_unit_roster_grid_renders(): void + { + $this->actingAs($this->owner) + ->get(route('care.care-units.roster', $this->unit)) + ->assertOk() + ->assertSee('Week roster') + ->assertSee('Day shift'); + } +}