diff --git a/app/Http/Controllers/Care/MyShiftsController.php b/app/Http/Controllers/Care/MyShiftsController.php new file mode 100644 index 0000000..df7d075 --- /dev/null +++ b/app/Http/Controllers/Care/MyShiftsController.php @@ -0,0 +1,44 @@ +authorizeAbility($request, 'nursing.my_shifts.view'); + $member = $this->member($request); + abort_unless($member, 403); + + $weekStart = $request->filled('week') + ? Carbon::parse($request->input('week'))->startOfWeek(Carbon::MONDAY) + : now()->startOfWeek(Carbon::MONDAY); + + $grid = $roster->memberWeek($member, $this->ownerRef($request), $weekStart); + + $byDate = []; + foreach ($grid['entries'] as $entry) { + $byDate[$entry->duty_date->toDateString()][] = $entry; + } + + return view('care.nursing.my-shifts', [ + 'member' => $member, + 'weekStart' => $grid['week_start'], + 'days' => $grid['days'], + 'entriesByDate' => $byDate, + 'todayEntries' => $grid['today'], + 'prevWeek' => $grid['week_start']->copy()->subWeek()->toDateString(), + 'nextWeek' => $grid['week_start']->copy()->addWeek()->toDateString(), + 'roles' => config('care.roles'), + ]); + } +} diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index 01ccbbd..0856007 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -498,6 +498,17 @@ class CarePermissions ], ]; + /** Ward / nursing-ops roles — not every clinical role with vitals (e.g. ambulance, GP). */ + protected array $nursingOpsRoles = [ + 'nurse', + 'ed_nurse', + 'theatre_nurse', + 'labour_ward_nurse', + 'fertility_nurse', + 'dialysis_nurse', + 'midwife', + ]; + /** Clinical roles that staff the floor (queues, specialty desks, counters). */ protected array $floorCareRoles = [ 'emergency_physician', 'ed_nurse', 'general_physician', 'doctor', @@ -566,11 +577,14 @@ class CarePermissions } } - return $this->withDerivedAbilities(array_keys($merged)); + return $this->withNursingOpsAbilities( + $this->withDerivedAbilities(array_keys($merged)), + $role, + ); } /** - * Floor clinicians who record vitals may also place patients on care units/beds. + * Clinical vitals unlock charting surfaces; ward nursing hub abilities are role-gated. * * @param list $abilities * @return list @@ -581,9 +595,8 @@ class CarePermissions return $abilities; } + // Clinical vitals → charting surfaces (not the Nursing Services admin hub). if (in_array('vitals.manage', $abilities, true)) { - $abilities[] = 'inpatient.placement.view'; - $abilities[] = 'inpatient.placement.manage'; $abilities[] = 'mar.view'; $abilities[] = 'mar.administer'; $abilities[] = 'nursing.notes.view'; @@ -593,9 +606,7 @@ 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'; + $abilities[] = 'nursing.my_shifts.view'; } if (in_array('analytics.department.view', $abilities, true)) { @@ -607,11 +618,42 @@ class CarePermissions $abilities[] = 'nursing.performance.view'; $abilities[] = 'nursing.roster.view'; $abilities[] = 'nursing.services.view'; + $abilities[] = 'nursing.my_shifts.view'; } return array_values(array_unique($abilities)); } + /** + * Ward nursing abilities (care units, roster manage, Nursing Services hub). + * Applied only for nursing-ops roles — not ambulance / GP / specialty MDs. + * + * @param list $abilities + * @return list + */ + protected function withNursingOpsAbilities(array $abilities, ?string $role): array + { + if (in_array('*', $abilities, true)) { + return $abilities; + } + + if ($role === null || ! in_array($role, $this->nursingOpsRoles, true)) { + return $abilities; + } + + if (! in_array('vitals.manage', $abilities, true)) { + return $abilities; + } + + $abilities[] = 'inpatient.placement.view'; + $abilities[] = 'inpatient.placement.manage'; + $abilities[] = 'nursing.roster.view'; + $abilities[] = 'nursing.roster.manage'; + $abilities[] = 'nursing.services.view'; + + return array_values(array_unique($abilities)); + } + public function isAdmin(?Member $member): bool { return $member !== null && in_array($member->role, ['super_admin', 'hospital_admin'], true); diff --git a/app/Services/Care/NursingServicesService.php b/app/Services/Care/NursingServicesService.php index 281e1e9..a32fd59 100644 --- a/app/Services/Care/NursingServicesService.php +++ b/app/Services/Care/NursingServicesService.php @@ -20,7 +20,6 @@ class NursingServicesService 'fertility_nurse', 'dialysis_nurse', 'midwife', - 'ambulance_staff', ]; /** diff --git a/app/Services/Care/RosterService.php b/app/Services/Care/RosterService.php index a9f55c6..9383fd3 100644 --- a/app/Services/Care/RosterService.php +++ b/app/Services/Care/RosterService.php @@ -240,6 +240,44 @@ class RosterService ->get(); } + /** + * Roster entries for one staff member over a week (or open-ended upcoming window). + * + * @return array{week_start: Carbon, days: list, entries: Collection, today: Collection} + */ + 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 $members * @return array diff --git a/resources/views/care/nursing/my-shifts.blade.php b/resources/views/care/nursing/my-shifts.blade.php new file mode 100644 index 0000000..62ba167 --- /dev/null +++ b/resources/views/care/nursing/my-shifts.blade.php @@ -0,0 +1,98 @@ + +
+
+
+

My shifts

+

+ Your duty roster for + {{ $weekStart->format('d M Y') }} – {{ $weekStart->copy()->addDays(6)->format('d M Y') }}. +

+
+ +
+ + @if ($todayEntries->isNotEmpty()) +
+

Today

+
    + @foreach ($todayEntries as $entry) +
  • +
    +

    {{ $entry->shift?->label ?? 'Shift' }}

    +

    + {{ $entry->shift?->timeLabel() }} + · {{ $entry->careUnit?->name ?? 'Unit' }} + @if ($entry->careUnit?->department) + ({{ $entry->careUnit->department->name }}) + @endif +

    +
    + + {{ config('care.roster_entry_statuses.'.$entry->status, $entry->status) }} + +
  • + @endforeach +
+
+ @else +
+ No duty assigned for today. +
+ @endif + +
+ + + + + + + + + + + @foreach ($days as $day) + @php $dayEntries = $entriesByDate[$day->toDateString()] ?? []; @endphp + @forelse ($dayEntries as $entry) + + + + + + + @empty + + + + + @endforelse + @endforeach + +
DayShiftUnitStatus
+ {{ $day->format('D') }} + {{ $day->format('d M') }} + @if ($day->isToday()) + Today + @endif + + + {{ $entry->shift?->label ?? '—' }} +
{{ $entry->shift?->timeLabel() }}
+
+ {{ $entry->careUnit?->name ?? '—' }} + @if ($entry->careUnit?->department) +
{{ $entry->careUnit->department->name }}
+ @endif +
+ {{ config('care.roster_entry_statuses.'.$entry->status, $entry->status) }} +
+ {{ $day->format('D') }} + {{ $day->format('d M') }} + Off / not rostered
+
+
+
diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 62ef978..75b2d7a 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -32,6 +32,11 @@ } } + if ($permissions->can($member, 'nursing.my_shifts.view')) { + $nav[] = ['name' => 'My shifts', 'route' => route('care.my-shifts'), 'active' => request()->routeIs('care.my-shifts'), + 'icon' => '']; + } + if (! empty($hasPaidPlan) && $permissions->can($member, 'displays.view')) { $nav[] = ['name' => 'Displays', 'route' => route('care.displays.index'), 'active' => request()->routeIs('care.displays.*'), 'icon' => '']; @@ -124,7 +129,10 @@ $adminNav[] = ['name' => 'Departments', 'route' => route('care.departments.index'), 'active' => request()->routeIs('care.departments.*'), 'icon' => '']; } - if ($permissions->can($member, 'admin.departments.view') || $permissions->can($member, 'inpatient.placement.view')) { + if ($permissions->can($member, 'admin.departments.view')) { + $adminNav[] = ['name' => 'Care units', 'route' => route('care.care-units.index'), 'active' => request()->routeIs('care.care-units.*'), + 'icon' => '']; + } elseif ($permissions->can($member, 'nursing.services.view') && $permissions->can($member, 'inpatient.placement.view')) { $adminNav[] = ['name' => 'Care units', 'route' => route('care.care-units.index'), 'active' => request()->routeIs('care.care-units.*'), 'icon' => '']; } diff --git a/routes/web.php b/routes/web.php index 1eb4ef8..0ac925c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -23,6 +23,7 @@ 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\MyShiftsController; use App\Http\Controllers\Care\DeviceController; use App\Http\Controllers\Care\DisplayPublicController; use App\Http\Controllers\Care\DisplayScreenController; @@ -471,6 +472,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::put('/shifts/{shift}', [ShiftController::class, 'update'])->name('care.shifts.update'); Route::get('/nursing/services', [NursingServicesController::class, 'index'])->name('care.nursing.services'); + Route::get('/my-shifts', [MyShiftsController::class, 'index'])->name('care.my-shifts'); 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'); diff --git a/tests/Feature/CareMyShiftsAndNavPermissionsTest.php b/tests/Feature/CareMyShiftsAndNavPermissionsTest.php new file mode 100644 index 0000000..9a71027 --- /dev/null +++ b/tests/Feature/CareMyShiftsAndNavPermissionsTest.php @@ -0,0 +1,170 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'my-shifts-owner', + 'name' => 'Owner', + 'email' => 'my-shifts-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Shifts Clinic', + 'slug' => 'shifts-clinic', + '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', + ]); + + $this->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' => $this->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, + ]); + } + + public function test_ambulance_staff_cannot_access_care_units_or_nursing_services_hub(): void + { + $user = User::create([ + 'public_id' => 'amb-staff', + 'name' => 'Ambulance', + 'email' => 'amb@example.com', + ]); + $member = Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $user->public_id, + 'role' => 'ambulance_staff', + 'branch_id' => $this->branch->id, + ]); + + $permissions = app(CarePermissions::class); + $this->assertFalse($permissions->can($member, 'nursing.services.view')); + $this->assertFalse($permissions->can($member, 'inpatient.placement.view')); + $this->assertFalse($permissions->can($member, 'admin.departments.view')); + $this->assertTrue($permissions->can($member, 'nursing.my_shifts.view')); + + $this->actingAs($user) + ->get(route('care.care-units.index')) + ->assertForbidden(); + + $this->actingAs($user) + ->get(route('care.nursing.services')) + ->assertForbidden(); + + $this->actingAs($user) + ->get(route('care.dashboard')) + ->assertOk() + ->assertDontSee('>Care units<', false) + ->assertDontSee('>Nursing Services<', false) + ->assertSee('My shifts', false); + } + + public function test_staff_see_their_assigned_shifts(): void + { + $user = User::create([ + 'public_id' => 'nurse-shifts', + 'name' => 'Nurse Ama', + 'email' => 'nurse-shifts@example.com', + ]); + $member = Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $user->public_id, + 'role' => 'nurse', + 'branch_id' => $this->branch->id, + ]); + + $roster = app(RosterService::class); + $shifts = $roster->ensureDefaultShifts($this->organization, $this->owner->public_id); + $day = $shifts->firstWhere('code', 'day'); + + $roster->assign( + $this->unit, + $day, + $member, + now()->toDateString(), + $this->owner->public_id, + $this->owner->public_id, + ); + + $this->actingAs($user) + ->get(route('care.my-shifts')) + ->assertOk() + ->assertSee('My shifts') + ->assertSee('Medical Ward') + ->assertSee('Day shift'); + } + + public function test_nurse_still_gets_nursing_services_access(): void + { + $member = Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => 'nurse-hub', + 'role' => 'nurse', + ]); + + $permissions = app(CarePermissions::class); + $this->assertTrue($permissions->can($member, 'nursing.services.view')); + $this->assertTrue($permissions->can($member, 'inpatient.placement.view')); + } +}