Skip to main content

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:

FieldRole
UnitCascading parent — filters the shift + employee dropdowns
ShiftDropdown of shifts belonging to the chosen unit
EmployeeDropdown of active employees whose primary deployment is there
From / ToInclusive date range
Skip weekendsCheckbox (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:

  • CreatePOST /roster with 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 / ReassignPOST /roster again for an existing slot auto-flips the old row to replaced. The same 403 gate applies per-row, so the scheduler can't rewrite historical slots.
  • LocumPOST /roster/{id}/locum routes through ShiftAssignmentPolicy::update; same gate.
  • CancelPOST /roster/{id}/cancel routes through the same policy method; same gate.
  • DeleteDELETE /roster/{id} routes through ShiftAssignmentPolicy::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 to replaced and lands the new row as planned. No locum_for_id link — just a schedule rewrite.
  • Assign a locum. Picks a cover; POSTs to /roster/{id}/locum. Creates a replaced + planned pair with locum_for_id pointing at the original employee so the cover is audit-visible.
  • Cancel this shift. POSTs to /roster/{id}/cancel (new endpoint). Flips status to cancelled; the row stays in the DB for audit and the by-shift heatmap collapses the cell back to W. 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.

Bulk upsert request

POST /api/v1/roster
Content-Type: application/json

{
"unit_id": 12,
"month": "2026-05",
"assignments": [
{ "employee_id": 101, "shift_id": 3, "date": "2026-05-01" },
{ "employee_id": 101, "shift_id": 3, "date": "2026-05-02" },
{ "employee_id": 102, "shift_id": 4, "date": "2026-05-01" }
]
}

Conflict Rules

A single employee cannot be assigned to two shifts that overlap in time on the same date. The server rejects the batch with 409 CONFLICT and pinpoints offending rows.

Mid-Month Changes

Changes mid-month are allowed but:

  • If the date is already in the past and an attendance row exists, the change is recorded as an audited edit and triggers recalculation.
  • If the date is locked, the change is rejected unless the caller is SuperAdmin, in which case it creates an unlock → edit → re-lock cycle.

Locum Replacement

sequenceDiagram
participant E as Employee
participant SM as ShiftManager
participant API as Laravel API

E->>API: POST /leaves (covers shift date)
SM->>API: POST /roster/{assignmentId}/locum { locum_employee_id }
API->>API: Create replacement shift_assignment
API->>API: Mark original as replaced
API->>API: Audit both rows

The original assignment stays in the history (status = replaced) and a new row captures the locum's assignment.

Publishing

A roster is published on confirmation; published rosters notify employees (push + Telegram + email). Unpublished drafts are visible only to ShiftManagers.