From 45da731561290aaf2093c0c73f73e7681408f93f Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 20 Jul 2026 15:54:26 +0000 Subject: [PATCH] Add My ward nurse station for bedside clinical work. Floor nurses keep Nursing Services admin-only, but now get a My ward home keyed off today's roster or unit placement with patient lists and direct links to MAR, vitals/assessments, notes, and handovers. Care unit show and board crumbs open for station users so clinical pages are reachable without department admin rights. Co-authored-by: Cursor --- .../Controllers/Care/CareUnitController.php | 3 +- .../Care/NurseStationController.php | 43 +++++ app/Services/Care/CarePermissions.php | 43 ++++- app/Services/Care/NurseStationService.php | 121 ++++++++++++++ .../care/admin/care-units/show.blade.php | 36 +++-- .../care/nursing/assessments-board.blade.php | 2 +- .../views/care/nursing/handovers.blade.php | 2 +- .../views/care/nursing/mar-board.blade.php | 2 +- .../views/care/nursing/my-shifts.blade.php | 14 +- resources/views/care/nursing/notes.blade.php | 2 +- .../nursing/partials/unit-crumb.blade.php | 9 ++ .../views/care/nursing/station.blade.php | 153 ++++++++++++++++++ resources/views/partials/sidebar.blade.php | 10 ++ routes/web.php | 2 + .../CareMyShiftsAndNavPermissionsTest.php | 97 +++++++++++ 15 files changed, 515 insertions(+), 24 deletions(-) create mode 100644 app/Http/Controllers/Care/NurseStationController.php create mode 100644 app/Services/Care/NurseStationService.php create mode 100644 resources/views/care/nursing/partials/unit-crumb.blade.php create mode 100644 resources/views/care/nursing/station.blade.php diff --git a/app/Http/Controllers/Care/CareUnitController.php b/app/Http/Controllers/Care/CareUnitController.php index 5bcef28..12a7302 100644 --- a/app/Http/Controllers/Care/CareUnitController.php +++ b/app/Http/Controllers/Care/CareUnitController.php @@ -155,7 +155,8 @@ class CareUnitController extends Controller $member = $this->member($request); abort_unless( $permissions->can($member, 'admin.departments.view') - || $permissions->can($member, 'inpatient.placement.view'), + || $permissions->can($member, 'inpatient.placement.view') + || $permissions->can($member, 'nursing.station.view'), 403, ); } diff --git a/app/Http/Controllers/Care/NurseStationController.php b/app/Http/Controllers/Care/NurseStationController.php new file mode 100644 index 0000000..091d3a5 --- /dev/null +++ b/app/Http/Controllers/Care/NurseStationController.php @@ -0,0 +1,43 @@ +authorizeAbility($request, 'nursing.station.view'); + $member = $this->member($request); + abort_unless($member, 403); + + $organization = $this->organization($request); + $unitId = $request->filled('unit') ? (int) $request->input('unit') : null; + $board = $station->board($organization, $member, $this->ownerRef($request), $unitId); + + $permissions = app(CarePermissions::class); + + return view('care.nursing.station', [ + 'units' => $board['units'], + 'patientsByUnit' => $board['patients_by_unit'], + 'dutyByUnit' => $board['duty_by_unit'], + 'placementByUnit' => $board['placement_by_unit'], + 'selectedUnit' => $board['selected_unit'], + 'canMar' => $permissions->can($member, 'mar.view'), + 'canAdminister' => $permissions->can($member, 'mar.administer'), + 'canNotes' => $permissions->can($member, 'nursing.notes.view'), + 'canHandover' => $permissions->can($member, 'nursing.handover.view'), + 'canAssess' => $permissions->can($member, 'nursing.assessments.view'), + 'canVitals' => $permissions->can($member, 'vitals.manage'), + 'canServicesHub' => $permissions->can($member, 'nursing.services.view'), + ]); + } +} diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index c4520c5..5b14dcb 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -576,8 +576,11 @@ class CarePermissions } } - return $this->withNursingOpsAbilities( - $this->withDerivedAbilities(array_keys($merged)), + return $this->withNursingStationAbility( + $this->withNursingOpsAbilities( + $this->withDerivedAbilities(array_keys($merged)), + $role, + ), $role, ); } @@ -653,6 +656,42 @@ class CarePermissions return array_values(array_unique($abilities)); } + /** + * Bedside nurse station (My ward) — floor + ward nursing roles with clinical vitals. + * Not granted to GPs / ambulance solely because they also chart vitals. + * + * @param list $abilities + * @return list + */ + protected function withNursingStationAbility(array $abilities, ?string $role): array + { + if (in_array('*', $abilities, true)) { + return $abilities; + } + + $nursingRoles = [ + 'nurse', + 'ed_nurse', + 'theatre_nurse', + 'labour_ward_nurse', + 'fertility_nurse', + 'dialysis_nurse', + 'midwife', + ]; + + if ($role === null || ! in_array($role, $nursingRoles, true)) { + return $abilities; + } + + if (! in_array('vitals.manage', $abilities, true)) { + return $abilities; + } + + $abilities[] = 'nursing.station.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/NurseStationService.php b/app/Services/Care/NurseStationService.php new file mode 100644 index 0000000..e812b49 --- /dev/null +++ b/app/Services/Care/NurseStationService.php @@ -0,0 +1,121 @@ +, + * patients_by_unit: array>, + * duty_by_unit: array>, + * placement_by_unit: array, + * selected_unit: ?CareUnit, + * } + */ + public function board( + Organization $organization, + Member $member, + string $ownerRef, + ?int $unitId = null, + ): array { + $dutyToday = RosterEntry::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('member_id', $member->id) + ->whereDate('duty_date', now()->toDateString()) + ->where('status', '!=', RosterEntry::STATUS_CANCELLED) + ->with(['shift', 'careUnit.department.branch']) + ->orderBy('shift_id') + ->get(); + + $placements = StaffAssignment::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('member_id', $member->id) + ->where('status', 'active') + ->unitPlacements() + ->effectiveOn(now()) + ->whereNotNull('care_unit_id') + ->with(['careUnit.department.branch']) + ->get(); + + $unitIds = $dutyToday->pluck('care_unit_id') + ->merge($placements->pluck('care_unit_id')) + ->filter() + ->map(fn ($id) => (int) $id) + ->unique() + ->values(); + + $units = CareUnit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->whereIn('id', $unitIds->all() ?: [0]) + ->where('is_active', true) + ->with('department.branch') + ->orderBy('name') + ->get() + ->keyBy('id'); + + // Preserve duty-first ordering, then remaining placements. + $ordered = collect(); + foreach ($dutyToday as $entry) { + $id = (int) $entry->care_unit_id; + if ($units->has($id) && ! $ordered->has($id)) { + $ordered->put($id, $units->get($id)); + } + } + foreach ($placements as $assignment) { + $id = (int) $assignment->care_unit_id; + if ($units->has($id) && ! $ordered->has($id)) { + $ordered->put($id, $units->get($id)); + } + } + + $selected = null; + if ($unitId && $ordered->has($unitId)) { + $selected = $ordered->get($unitId); + } elseif ($ordered->isNotEmpty()) { + $selected = $ordered->first(); + } + + $patientsByUnit = []; + foreach ($ordered as $id => $unit) { + $patientsByUnit[$id] = Visit::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('care_unit_id', $id) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->with(['patient', 'bed']) + ->orderBy('placed_at') + ->get(); + } + + $dutyByUnit = []; + foreach ($dutyToday as $entry) { + $id = (int) $entry->care_unit_id; + $dutyByUnit[$id] ??= collect(); + $dutyByUnit[$id]->push($entry); + } + + $placementByUnit = []; + foreach ($placements as $assignment) { + $placementByUnit[(int) $assignment->care_unit_id] = $assignment; + } + + return [ + 'units' => $ordered->values(), + 'patients_by_unit' => $patientsByUnit, + 'duty_by_unit' => $dutyByUnit, + 'placement_by_unit' => $placementByUnit, + 'selected_unit' => $selected, + ]; + } +} diff --git a/resources/views/care/admin/care-units/show.blade.php b/resources/views/care/admin/care-units/show.blade.php index 38f7521..1d74c02 100644 --- a/resources/views/care/admin/care-units/show.blade.php +++ b/resources/views/care/admin/care-units/show.blade.php @@ -2,8 +2,29 @@
+ @php + $member = auth()->user() + ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user()) + : null; + $permissions = app(\App\Services\Care\CarePermissions::class); + $canAdminUnits = $permissions->can($member, 'admin.departments.view'); + $canStation = $permissions->can($member, 'nursing.station.view'); + $canManageUnit = $permissions->can($member, 'admin.departments.manage'); + $canMar = $permissions->can($member, 'mar.view'); + $canNotes = $permissions->can($member, 'nursing.notes.view'); + $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

- Care units + @if ($canAdminUnits) + Care units + @elseif ($canStation) + My ward + @else + Care units + @endif / {{ $unit->department?->name }}

@@ -18,19 +39,6 @@

- @php - $member = auth()->user() - ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user()) - : null; - $permissions = app(\App\Services\Care\CarePermissions::class); - $canManageUnit = $permissions->can($member, 'admin.departments.manage'); - $canMar = $permissions->can($member, 'mar.view'); - $canNotes = $permissions->can($member, 'nursing.notes.view'); - $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 diff --git a/resources/views/care/nursing/assessments-board.blade.php b/resources/views/care/nursing/assessments-board.blade.php index df210e9..aaecdee 100644 --- a/resources/views/care/nursing/assessments-board.blade.php +++ b/resources/views/care/nursing/assessments-board.blade.php @@ -3,7 +3,7 @@

- {{ $unit->name }} + @include('care.nursing.partials.unit-crumb', ['unit' => $unit]) / Nursing assessments

diff --git a/resources/views/care/nursing/handovers.blade.php b/resources/views/care/nursing/handovers.blade.php index 5b4a2e3..2eb5fc1 100644 --- a/resources/views/care/nursing/handovers.blade.php +++ b/resources/views/care/nursing/handovers.blade.php @@ -3,7 +3,7 @@

- {{ $unit->name }} + @include('care.nursing.partials.unit-crumb', ['unit' => $unit]) / Ward handovers

diff --git a/resources/views/care/nursing/mar-board.blade.php b/resources/views/care/nursing/mar-board.blade.php index 3c34abf..bffd472 100644 --- a/resources/views/care/nursing/mar-board.blade.php +++ b/resources/views/care/nursing/mar-board.blade.php @@ -16,7 +16,7 @@

- {{ $unit->name }} + @include('care.nursing.partials.unit-crumb', ['unit' => $unit]) / Medication round

diff --git a/resources/views/care/nursing/my-shifts.blade.php b/resources/views/care/nursing/my-shifts.blade.php index 62ba167..54c0a94 100644 --- a/resources/views/care/nursing/my-shifts.blade.php +++ b/resources/views/care/nursing/my-shifts.blade.php @@ -31,9 +31,17 @@ @endif

- - {{ config('care.roster_entry_statuses.'.$entry->status, $entry->status) }} - +
+ @if ($entry->careUnit && app(\App\Services\Care\CarePermissions::class)->can( + auth()->user() ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user()) : null, + 'nursing.station.view' + )) + Open ward + @endif + + {{ config('care.roster_entry_statuses.'.$entry->status, $entry->status) }} + +
@endforeach diff --git a/resources/views/care/nursing/notes.blade.php b/resources/views/care/nursing/notes.blade.php index 3f46148..d26e3fd 100644 --- a/resources/views/care/nursing/notes.blade.php +++ b/resources/views/care/nursing/notes.blade.php @@ -2,7 +2,7 @@

- {{ $unit->name }} + @include('care.nursing.partials.unit-crumb', ['unit' => $unit]) / Nursing notes

diff --git a/resources/views/care/nursing/partials/unit-crumb.blade.php b/resources/views/care/nursing/partials/unit-crumb.blade.php new file mode 100644 index 0000000..a9bdf6a --- /dev/null +++ b/resources/views/care/nursing/partials/unit-crumb.blade.php @@ -0,0 +1,9 @@ +@php + $crumbMember = auth()->user() + ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user()) + : null; + $permissions = app(\App\Services\Care\CarePermissions::class); + $crumbToStation = $permissions->can($crumbMember, 'nursing.station.view') + && ! $permissions->can($crumbMember, 'admin.departments.view'); +@endphp +{{ $unit->name }} diff --git a/resources/views/care/nursing/station.blade.php b/resources/views/care/nursing/station.blade.php new file mode 100644 index 0000000..e700003 --- /dev/null +++ b/resources/views/care/nursing/station.blade.php @@ -0,0 +1,153 @@ + +
+
+
+

My ward

+

+ Patients on units you’re rostered or placed on today — chart meds, vitals, notes, and handovers. +

+
+
+ My shifts + @if ($canServicesHub) + Nursing Services + @endif +
+
+ + @if ($units->isEmpty()) +
+

No unit assigned for today

+

+ Ask a charge nurse or administrator to put you on the duty roster or a unit placement. + You can still check + My shifts. +

+
+ @else + @if ($units->count() > 1) + + @endif + + @php + $unit = $selectedUnit; + $patients = $patientsByUnit[$unit->id] ?? collect(); + $duty = $dutyByUnit[$unit->id] ?? collect(); + $placement = $placementByUnit[$unit->id] ?? null; + @endphp + +
+
+
+

{{ $unit->name }}

+

+ {{ $unit->department?->name }} + @if ($unit->department?->branch) + · {{ $unit->department->branch->name }} + @endif +

+
+ @foreach ($duty as $entry) + + + On duty · {{ $entry->shift?->label ?? 'Shift' }} + @if ($entry->shift) + ({{ $entry->shift->timeLabel() }}) + @endif + + @endforeach + @if ($placement && $duty->isEmpty()) + + Unit placement · {{ str_replace('_', ' ', $placement->kind) }} + + @elseif ($placement && $duty->isNotEmpty()) + + Also placed · {{ str_replace('_', ' ', $placement->kind) }} + + @endif +
+
+
+ @if ($canMar) + MAR round + @endif + @if ($canAssess || $canVitals) + Vitals & assess + @endif + @if ($canNotes) + Notes + @endif + @if ($canHandover) + Handover + @endif +
+
+ +
+ + + + + + + + + + + @forelse ($patients as $visit) + + + + + + + @empty + + + + @endforelse + +
PatientBedPlacedChart
+

{{ $visit->patient?->fullName() ?? 'Patient' }}

+ @if ($visit->patient?->patient_number) +

{{ $visit->patient->patient_number }}

+ @endif +
{{ $visit->bed?->label ?? '—' }} + {{ $visit->placed_at?->format('d M H:i') ?? '—' }} + + @if ($canMar) + MAR + @endif + @if ($canAssess || $canVitals) + Vitals + @endif + @if ($canNotes) + Notes + @endif +
+ No patients placed on this unit yet. + @if ($canServicesHub) + Placements are managed from the care unit board. + @else + Ask a charge nurse to place patients on this unit. + @endif +
+
+
+ @endif +
+
diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index f9f9164..b000a35 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -32,6 +32,16 @@ } } + if ($permissions->can($member, 'nursing.station.view')) { + $nav[] = ['name' => 'My ward', 'route' => route('care.nursing.station'), 'active' => request()->routeIs('care.nursing.station') + || (request()->routeIs('care.care-units.mar') + || request()->routeIs('care.care-units.notes') + || request()->routeIs('care.care-units.handovers*') + || request()->routeIs('care.care-units.assessments') + || request()->routeIs('care.visits.mar')), + 'icon' => '']; + } + if ($permissions->can($member, 'nursing.my_shifts.view')) { $nav[] = ['name' => 'My shifts', 'route' => route('care.my-shifts'), 'active' => request()->routeIs('care.my-shifts'), 'icon' => '']; diff --git a/routes/web.php b/routes/web.php index 47bf8b4..5da3f80 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\NurseStationController; use App\Http\Controllers\Care\MyShiftsController; use App\Http\Controllers\Care\DeviceController; use App\Http\Controllers\Care\DisplayPublicController; @@ -474,6 +475,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('/nursing/station', [NurseStationController::class, 'index'])->name('care.nursing.station'); Route::get('/my-shifts', [MyShiftsController::class, 'index'])->name('care.my-shifts'); Route::get('/staff-assignments', [StaffAssignmentController::class, 'index'])->name('care.staff-assignments.index'); diff --git a/tests/Feature/CareMyShiftsAndNavPermissionsTest.php b/tests/Feature/CareMyShiftsAndNavPermissionsTest.php index 22dbcfc..17b403c 100644 --- a/tests/Feature/CareMyShiftsAndNavPermissionsTest.php +++ b/tests/Feature/CareMyShiftsAndNavPermissionsTest.php @@ -101,6 +101,7 @@ class CareMyShiftsAndNavPermissionsTest extends TestCase $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->assertFalse($permissions->can($member, 'nursing.station.view')); $this->actingAs($user) ->get(route('care.care-units.index')) @@ -110,11 +111,16 @@ class CareMyShiftsAndNavPermissionsTest extends TestCase ->get(route('care.nursing.services')) ->assertForbidden(); + $this->actingAs($user) + ->get(route('care.nursing.station')) + ->assertForbidden(); + $this->actingAs($user) ->get(route('care.dashboard')) ->assertOk() ->assertDontSee('>Care units<', false) ->assertDontSee('>Nursing Services<', false) + ->assertDontSee('>My ward<', false) ->assertSee('My shifts', false); } @@ -174,6 +180,9 @@ class CareMyShiftsAndNavPermissionsTest extends TestCase $this->assertFalse($permissions->can($member, 'inpatient.placement.view')); $this->assertFalse($permissions->can($member, 'nursing.roster.manage')); $this->assertTrue($permissions->can($member, 'nursing.my_shifts.view')); + $this->assertTrue($permissions->can($member, 'nursing.station.view')); + $this->assertTrue($permissions->can($member, 'mar.view')); + $this->assertTrue($permissions->can($member, 'nursing.notes.manage')); $this->assertFalse($permissions->can($member, 'admin.departments.view')); $this->assertFalse($permissions->can($member, 'admin.departments.manage')); @@ -201,6 +210,12 @@ class CareMyShiftsAndNavPermissionsTest extends TestCase ->get(route('care.specialty.show', 'nursing_services')) ->assertForbidden(); + $this->actingAs($user) + ->get(route('care.nursing.station')) + ->assertOk() + ->assertSee('My ward') + ->assertSee('No unit assigned for today'); + $this->actingAs($user) ->get(route('care.dashboard')) ->assertOk() @@ -208,6 +223,88 @@ class CareMyShiftsAndNavPermissionsTest extends TestCase ->assertDontSee('>Departments<', false) ->assertDontSee('>Nursing Services<', false) ->assertDontSee('>Nursing<', false) + ->assertSee('>My ward<', false) ->assertSee('My shifts', false); } + + public function test_regular_nurse_station_shows_rostered_unit_patients_and_clinical_boards(): void + { + $user = User::create([ + 'public_id' => 'nurse-station', + 'name' => 'Nurse Kojo', + 'email' => 'nurse-station@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); + $day = $roster->ensureDefaultShifts($this->organization, $this->owner->public_id)->firstWhere('code', 'day'); + $roster->assign( + $this->unit, + $day, + $member, + now()->toDateString(), + $this->owner->public_id, + $this->owner->public_id, + ); + + $patient = \App\Models\Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-WARD-1', + 'first_name' => 'Ama', + 'last_name' => 'Mensah', + ]); + \App\Models\Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'care_unit_id' => $this->unit->id, + 'status' => \App\Models\Visit::STATUS_OPEN, + 'checked_in_at' => now(), + 'placed_at' => now(), + ]); + + $this->actingAs($user) + ->get(route('care.nursing.station')) + ->assertOk() + ->assertSee('Medical Ward') + ->assertSee('Ama Mensah') + ->assertSee('MAR round') + ->assertSee('Vitals & assess', false) + ->assertSee('Notes') + ->assertSee('Handover'); + + $this->actingAs($user) + ->get(route('care.care-units.mar', $this->unit)) + ->assertOk() + ->assertSee('MAR board'); + + $this->actingAs($user) + ->get(route('care.care-units.notes', $this->unit)) + ->assertOk() + ->assertSee('Nursing notes'); + + $this->actingAs($user) + ->get(route('care.care-units.assessments', $this->unit)) + ->assertOk() + ->assertSee('Ward assessment board'); + + $this->actingAs($user) + ->get(route('care.care-units.show', $this->unit)) + ->assertOk() + ->assertSee('Ama Mensah'); + + $this->actingAs($user) + ->get(route('care.my-shifts')) + ->assertOk() + ->assertSee('Open ward'); + } }