Roster Management
Creating a roster (UI)
A scheduler with shift_assignment.create (HR, scheduler, org_admin,
system_admin) sees an "Assign shifts" button in the top-right of
/roster. Clicking it opens a dialog with:
| Field | Role |
|---|---|
| Unit | Cascading parent — filters the shift + employee dropdowns |
| Shift | Dropdown of shifts belonging to the chosen unit |
| Employee | Dropdown of active employees whose primary deployment is there |
| From / To | Inclusive date range |
| Skip weekends | Checkbox (default on). Sat/Sun are excluded from generated rows |
A live preview shows how many rows will be created. Submit POSTs every
date in the range to /api/v1/roster in one call. Any existing
planned shift on the same (shift, date) slot is auto-flipped to
replaced so the new assignee wins — same semantics as a locum swap,
just without the locum_for_id link.
Conflict handling
The server rejects the whole payload with 422 if ANY of these are detected:
- Two rows in the same request would land an employee on overlapping shifts the same day.
- A row would land the employee on a shift that overlaps an already-planned shift at a different unit/org on the same day.
Response body is:
{
"message": "Roster has scheduling conflicts; none of the requested rows were saved.",
"conflicts": [
{
"employee_id": 101,
"date": "2026-05-12",
"reason": "Shift AM (09:00–17:00) overlaps with an existing assignment at Facility B: NIGHT (21:00–05:00)."
}
]
}
The dialog renders each conflict inline; the scheduler picks a different employee / shift / range and re-submits. Nothing is partially persisted — the DB transaction rolls back cleanly.
Back-dated edits — HR only
The scheduler role is forward-looking by design. Any roster row
whose assigned_for < today requires the new
shift_assignment.manage_past permission, which is granted to HR,
org_admin, and system_admin but NOT to scheduler. Behaviours:
- Create —
POST /rosterwith a back-dated row from a scheduler returns 403 before anything is saved; the error message names today's date so the client can show a useful inline message. - Update / Reassign —
POST /rosteragain for an existing slot auto-flips the old row toreplaced. The same 403 gate applies per-row, so the scheduler can't rewrite historical slots. - Locum —
POST /roster/{id}/locumroutes throughShiftAssignmentPolicy::update; same gate. - Cancel —
POST /roster/{id}/cancelroutes through the same policy method; same gate. - Delete —
DELETE /roster/{id}routes throughShiftAssignmentPolicy::delete; same gate.
"Today" uses the server's config('app.timezone') for comparison,
so same-day re-assigns (the classic "someone called in sick at 11
AM" flow) stay scheduler territory — the tripwire is strictly
< today, not <= today.
In the UI, a scheduler looking at a past-date cell sees the tooltip normally but the cell isn't clickable — no edit dialog opens. The Assign shifts dialog clamps the From date picker to today for schedulers; a subtle amber banner explains why.
Editing an existing assignment
Click any coloured cell in either heatmap view. An Edit assignment dialog opens with the current context (employee, shift, date, locum- for if applicable) and three action buttons:
- Reassign to a different employee. Picks a new person and POSTs
to
/roster; the server flips the current planned row toreplacedand lands the new row asplanned. Nolocum_for_idlink — just a schedule rewrite. - Assign a locum. Picks a cover; POSTs to
/roster/{id}/locum. Creates areplaced + plannedpair withlocum_for_idpointing at the original employee so the cover is audit-visible. - Cancel this shift. POSTs to
/roster/{id}/cancel(new endpoint). Flips status tocancelled; the row stays in the DB for audit and the by-shift heatmap collapses the cell back toW. Reversible by simply scheduling the same slot again.
Only users with shift_assignment.update see clickable cells.
Cells coded H (holiday) or W (unassigned) aren't clickable.
Assigning a locum (direct API)
From code, skip the dialog:
POST /api/v1/roster/{assignment}/locum
{ "locum_employee_id": 42 }
The service flips the original row to replaced, inserts a new
planned row for the locum with locum_for_id = original.employee_id,
and emits a roster.locum_assign audit record. The by-shift cell
becomes L (blue) on refresh, with the hover payload listing the
locum and covering for: <original name>.
Monthly Planning
ShiftManagers build the roster by the 25th of the previous month. The roster is a grid of (employee × date) cells where each cell is one shift_assignments row.