@extends('layouts.app') @section('title', 'Duty Roster') @section('content') {{-- DUTY ROSTER — REDESIGN (May 3 2026) ---------------------------------------------------------------------- Layout: 7-day weekly table with 8 fixed rows DAY SHIFT × { Clinical Officer, Nurse, Receptionist, Support Staff } NIGHT SHIFT × { Clinical Officer, Nurse, Receptionist, Support Staff } Database is unchanged — DutyRoster still stores by (date, shift, staff_record_id|locum_id, department_id). The role row is derived at render time from the staff member's `designation` (or the locum's `role_designation`). Anything that doesn't match the four canonical buckets falls into a single "Other" group rendered below the main table only when it actually has entries — preserves data visibility for existing rosters that include other roles. Print/PDF: print uses the inline @media print rules below. Export PDF triggers the same print dialog (browser-side "Save as PDF"). --}} @php /** * Edit permission: only HR User and admins can add/remove entries. */ $rosterCanEdit = Auth::user() && Auth::user()->hasAnyRole('Super Admin', 'Hospital Admin', 'HR User'); /** * Map a free-text designation/locum role string to one of the four * canonical role buckets. Anything that doesn't match returns 'other' * — those entries are rendered in a separate group below the main * table so existing roster data with non-canonical roles is not * silently dropped from the UI. * * Order of checks matters: most specific first ("Clinical Officer" * before generic "Officer"; "Nursing Assistant" before "Nurse"). */ $roleBucket = function (?string $designation): string { $d = strtolower(trim($designation ?? '')); if ($d === '') return 'other'; // Volunteer Nurse: nursing coverage is nursing coverage regardless // of pay status. Must come BEFORE the generic volunteer catch-all // below so "volunteer nurse" doesn't get swallowed into Support. if (str_contains($d, 'volunteer nurse')) return 'nurse'; // CNA / Nursing Assistant — must come BEFORE the Nurse check if (str_contains($d, 'cna') || str_contains($d, 'nursing assistant') || str_contains($d, 'care assistant') || str_contains($d, 'patient attendant')) return 'cna'; // Clinical Officer / Doctor / Medical Officer if (str_contains($d, 'clinical officer') || str_contains($d, 'medical officer') || str_contains($d, 'doctor') || str_contains($d, 'clinician') || str_contains($d, 'physician') || str_contains($d, 'consultant')) return 'clinical'; // Nurse (after CNA so it doesn't swallow nursing assistants) if (str_contains($d, 'nurse') || str_contains($d, 'midwife')) return 'nurse'; // Receptionist / Front desk / Admin reception if (str_contains($d, 'reception') || str_contains($d, 'front desk') || str_contains($d, 'cashier')) return 'receptionist'; // Volunteer roles & other support designations all fold into the // Support Staff row. Volunteer Nurse already routed to 'nurse' // above so it's the one volunteer designation that doesn't land // here. Cleaners, porters, drivers, interns, records clerks, // community health volunteers, generic "Volunteer" all match. if (str_contains($d, 'volunteer') || str_contains($d, 'cleaner') || str_contains($d, 'porter') || str_contains($d, 'driver') || str_contains($d, 'intern') || str_contains($d, 'records clerk') || str_contains($d, 'support')) return 'cna'; return 'other'; }; /** * Bucket every roster entry into [shift][roleKey][date] => [entries…] * so the table render is a clean nested loop without per-cell filtering. */ $bucketed = [ 'day' => ['clinical' => [], 'nurse' => [], 'receptionist' => [], 'cna' => [], 'other' => []], 'night' => ['clinical' => [], 'nurse' => [], 'receptionist' => [], 'cna' => [], 'other' => []], ]; foreach (['day', 'night'] as $shiftKey) { $byDate = $rosterGrid[$shiftKey] ?? []; foreach ($byDate as $dateStr => $entries) { foreach ($entries as $entry) { $designation = optional($entry->staffRecord)->designation ?? optional($entry->locum)->role_designation ?? ''; $bucket = $roleBucket($designation); $bucketed[$shiftKey][$bucket][$dateStr][] = $entry; } } } // Should we render the "Other" group? Only if there's actually data // in it on either shift. Keeps the canonical 8-row table clean when // the whole week maps neatly. $showOtherDay = ! empty($bucketed['day']['other']); $showOtherNight = ! empty($bucketed['night']['other']); $showOtherGroup = $showOtherDay || $showOtherNight; // Roles to render in the canonical table (always all four). $roles = [ 'clinical' => 'Clinician', 'nurse' => 'Nurse', 'receptionist' => 'Receptionist', 'cna' => 'Support Staff', ]; // Shift metadata. $shifts = [ 'day' => ['label' => 'DAY', 'time' => '08:00 – 20:00'], 'night' => ['label' => 'NIGHT', 'time' => '20:00 – 08:00'], ]; @endphp
{{-- ─── Print-only header (visible only on @media print) ─── --}}

CLARA ROSA HOSPITAL — WEEKLY DUTY ROSTER

Week of {{ $weekStart->format('d M Y') }} – {{ $weekEnd->format('d M Y') }}

{{-- ─── Page header & navigation ─── --}}

Duty Roster

Week of {{ $weekStart->format('d M') }} – {{ $weekEnd->format('d M Y') }}

{{-- Department filter --}}
@if($rosterCanEdit) @endif
@if($filterDeptId) @php $filterDeptName = $departments->firstWhere('id', $filterDeptId)?->name ?? 'Unknown'; @endphp
Filtered: {{ $filterDeptName }} ×
@endif {{-- ─── Roster table ─── --}}
@foreach($weekDates as $d) @php $isToday = $d->isToday(); @endphp @endforeach @foreach($shifts as $shiftKey => $shiftMeta) @php $rolesForThisShift = $roles; @endphp @foreach($rolesForThisShift as $roleKey => $roleLabel) {{-- Shift cell — rendered only on the FIRST role row of this shift, with rowspan=4 --}} @if($loop->first) @endif {{-- Role cell (column 2) — tinted per spec --}} {{-- 7 day cells --}} @foreach($weekDates as $d) @php $isToday = $d->isToday(); $dateStr = $d->toDateString(); $entries = $bucketed[$shiftKey][$roleKey][$dateStr] ?? []; @endphp @endforeach @endforeach @endforeach
Shift Role {{ $d->format('D') }} {{ $d->format('d M') }}
{{ $shiftMeta['label'] }} {{ $shiftMeta['time'] }} {{ $roleLabel }}
@if(empty($entries)) @if($rosterCanEdit) + Assign @else @endif @else @foreach($entries as $entry) @php $isLocum = $entry->assignee_type === 'locum'; $isVolunteer = $entry->assignee_type === 'volunteer'; $name = $entry->assignee_name; @endphp @if($rosterCanEdit) {{ $name }} @if($isLocum)L@endif @if($isVolunteer)V@endif @else {{ $name }} @if($isLocum)L@endif @if($isVolunteer)V@endif @endif @endforeach {{-- "+ add another" affordance removed by request: with one person per role per shift the typical case, the inline plus on populated cells just adds visual noise. To add a second assignee to a filled slot, use the full "Add Rota Entry" button at the top. --}} @endif
{{-- ─── "Other" roles group — rendered only when there is data in it ─── --}} {{-- Preserves visibility of pre-existing roster entries whose designation doesn't map to one of the four canonical role buckets. Without this, deploying the redesign would silently hide e.g. "Lab Technologist" entries that already exist on the current week. --}} @if($showOtherGroup)

Other Roles

Staff whose designation doesn't map to the canonical four role rows above.

@foreach($shifts as $shiftKey => $shiftMeta) @if(! empty($bucketed[$shiftKey]['other'])) @foreach($weekDates as $d) @php $isToday = $d->isToday(); $dateStr = $d->toDateString(); $entries = $bucketed[$shiftKey]['other'][$dateStr] ?? []; @endphp @endforeach @endif @endforeach
{{ $shiftMeta['label'] }} {{ $shiftMeta['time'] }} Other
@if(empty($entries)) @else @foreach($entries as $entry) @php $isLocum = $entry->assignee_type === 'locum'; $isVolunteer = $entry->assignee_type === 'volunteer'; $name = $entry->assignee_name; $desig = optional($entry->staffRecord)->designation ?? optional($entry->locum)->role_designation ?? ''; @endphp @if($rosterCanEdit) {{ $name }} @if($isLocum)L@endif @if($isVolunteer)V@endif @else {{ $name }} @if($isLocum)L@endif @if($isVolunteer)V@endif @endif @endforeach @endif
@endif
{{-- /.dr-root --}} {{-- ─── Add Rota Entry Modal (preserved 1:1 from prior version) ─── --}} @if($rosterCanEdit) @endif @endsection