Queueing & Service Stages — Frontend Integration
A BranchService (e.g. Consultation, Lasik) is a workflow with multiple ordered stages (Reception → Refraction → Dilation → Billing → Checked Out). A patient holds one queue ticket from registration through checkout; each stage is a separate row in the visit history with its own waiting position.
This page covers the queueing API (register a ticket, walk it through the lifecycle, attach billing/medicine), the stage flow API (move between stages, work a per-stage queue), the visibility rules, and recommended Pinia / composable shape.
Mental model
Branch
└── BranchService "Consultation", "Lasik"
└── ServiceStage "Reception", "Refraction", ...
└── TicketStageVisit one row per stage entry by a ticketKey invariants:
- A ticket's
branch_service_iddoes not change. Moving stages does not move the ticket between services. - The
ticket_number(e.g.CON-001) does not change as the patient progresses. - Each stage maintains its own per-day sequence (
position). A patient can beCON-001overall but#3in line at Refraction. - Movement between stages is flexible — staff can move forward, backward, skip, or revisit any stage.
- Every service that has stages has exactly one stage with
is_initial=true(the entry point) and zero or more stages withis_terminal=true(completion points). Registration auto-enters theis_initialstage. Completing a visit on a stage withis_terminal=trueflips the parent ticket'sstatustocompleted.
Visibility rules
The frontend sees only what the API exposes. Server-side scopes filter every list endpoint.
| User has access to | Source of truth |
|---|---|
| Branches | users.default_branch_id and branch staff |
| Services in the branch | branch_service_user pivot |
| Stages in a service | service_stage_user pivot |
A staff member attached to Reception (only) sees the Reception queue and can act on Reception visits — but not Refraction or Billing. Admins with the service-stages.manage permission bypass attachment checks.
Practical UI consequence: render the stage tabs in a service screen using GET /branches/{branch}/services-with-stages (which already filters by both attachments). Don't try to derive visibility on the client.
Enums
Queue ticket status (status.value on QueueTicket)
The whole ticket's lifecycle.
| Value | Meaning |
|---|---|
waiting | In the queue, not yet called |
called | Staff called the patient to a counter |
in_service | Patient is currently being served at the counter |
completed | Whole ticket finished. Auto-set when a is_terminal stage visit completes |
transferred | Ticket was sent to another service; a new ticket was created |
skipped | Patient was skipped (didn't respond when called) |
no_show | Patient did not arrive |
checked_in | Initial state assigned when registering — the ticket exists and is queued |
checked_out | Patient has left the premises (post-completion convenience state) |
Each status field on a QueueTicket is returned as an object: { value, label, color }. The label is human-readable and the color is a tailwind-friendly name (gray, yellow, blue, green, red, ...) — render badges directly from the response.
Stage visit status (status on TicketStageVisit)
A single visit's lifecycle within one stage.
| Value | Meaning |
|---|---|
waiting | Patient is in line at this stage |
called | Stage staff called them to the station |
in_service | Stage staff is actively serving them |
completed | Stage work is done. Either a manual complete, or auto-closed when the patient moves to the next stage |
skipped | Stage was skipped (patient bypassed it) |
This field is a plain string on the visit (not an object).
Priority (priority_type.value on QueueTicket)
Lower number = higher priority in the queue order.
| Value | Label | When to use |
|---|---|---|
1 | Emergency | Walk-in emergencies — front of queue |
2 | PWD | Persons with disabilities |
3 | Senior | Senior citizens |
4 | Regular | Default |
Returned as { value, label, color } like status — render directly.
API endpoints
All endpoints require Authorization: Bearer {token}. The queue routes (/visits/..., /branch-services/{service}/queue, /queue-tickets/...) sit under the branch.context middleware — send X-Branch-Id: {branch_id} on those calls. The stage admin and stage-flow routes (/branches/{branch}/..., /branch-services/{service}/stages/..., /service-stages/..., /ticket-stage-visits/...) use URL-based branch resolution and don't need the header.
Patient visits — prerequisite for registering a ticket
A patient's trip to the clinic on a given day is a PatientVisit. Each ticket belongs to one visit. A single visit can spawn multiple tickets if the patient is registered into more than one service that day. Create the visit before you can register a ticket.
| Method | Path | What it does |
|---|---|---|
| POST | /api/v1/visits | Open a new visit for a patient at a branch. Returns the visit so you can use its id when registering a ticket. |
| GET | /api/v1/visits/{visit} | Fetch a visit and its details — useful for the patient header on the queue UI. |
| PUT | /api/v1/visits/{visit}/complete | Close the whole visit. Independent of queue ticket completion — call this when you're done with the patient for the day. |
Register a ticket
POST /api/v1/branch-services/{branchService}/queue
What it does: Creates a new queue ticket for a patient at a specific branch service. Generates the human-readable ticket_number (e.g. CON-001) using a per-service-per-day counter. If the service has stages defined, the ticket is automatically placed into the entry stage (is_initial=true) — no second call needed. The registering user does not need to be attached to the entry stage.
Body:
{
"patient_id": 123,
"visit_id": 456,
"priority_type": 4,
"is_appointment": false,
"appointment_id": null,
"notes": null
}Validation:
patient_id— required, must exist inpatientsvisit_id— required, must exist inpatient_visitspriority_type— required, integer 1–4 (Emergency / PWD / Senior / Regular)is_appointment— optional boolean, defaultfalseappointment_id— required whenis_appointment=true; must exist insurgery_schedulesnotes— optional string, max 1000 chars
Response: 201 Created with the new ticket. If the service has stages configured, current_stage_visit is auto-populated with the entry-stage visit (the registering user does not need to be attached to that stage):
{
"message": "Patient registered in queue.",
"data": {
"id": 1001,
"ticket_number": "CON-001",
"ticket_sequence": 1,
"status": { "value": "checked_in", "label": "Checked In", "color": "violet" },
"priority_type": { "value": 4, "label": "Regular", "color": "blue" },
"current_stage_visit": {
"id": 42,
"service_stage_id": 11,
"status": "waiting",
"position": 1,
"stage": { "id": 11, "code": "REC", "name": "Reception", "position": 1, "is_initial": true, "is_terminal": false }
}
}
}List the queue for a service
GET /api/v1/branch-services/{branchService}/queue?perPage=15&search=cruz&service=11&date=2026-05-08
What it does: Returns the paginated active queue for the user's accessible branch services. Each ticket includes its current_stage_visit (eager-loaded) so the UI can show "where the patient is right now" without an extra request. Use this to render the main queue board.
Query params (all optional):
perPage— page size, default15search— matches againstticket_numberand patient first/last/full nameservice—branch_service_idto narrow the list (useful when a user is attached to multiple services)date—YYYY-MM-DD, defaults to today
{
"message": "Queue retrieved successfully.",
"data": [ QueueTicket[] ], // each includes current_stage_visit eager-loaded
"links": { ... },
"meta": { ... }
}Ordering: completed_at ASC, priority_type ASC, updated_at DESC, ticket_sequence ASC — handled server-side, no need to sort client-side.
Ticket-level actions
These mutate the ticket as a whole (not a specific stage visit). All return 200 with the updated ticket on success.
| Method | Path | What it does |
|---|---|---|
| PUT | /api/v1/queue-tickets/{ticket}/call | Marks the ticket as called to a counter. Records who called (called_by_user_id) and when (called_at). |
| PUT | /api/v1/queue-tickets/{ticket}/serve | Patient has arrived at the counter; staff is now serving them. Records who served and when. |
| PUT | /api/v1/queue-tickets/{ticket}/complete | Marks the ticket finished. Stops it from appearing in active queue lists. |
| PUT | /api/v1/queue-tickets/{ticket}/skip | Patient didn't respond when called. Pushes them down the queue (the server bumps registered_at to "now"). |
| PUT | /api/v1/queue-tickets/{ticket}/no-show | Patient didn't arrive at all. Removes them from the active queue without completing them. |
| POST | /api/v1/queue-tickets/{ticket}/transfer | Sends the ticket to a different service. Creates a NEW ticket linked back via transferred_from_id. |
When to use ticket-level vs stage-level actions: if the service has stages configured, prefer the stage-level actions on
/api/v1/ticket-stage-visits/{visit}/...— they record the action against the specific stage visit and keep the per-stage queue accurate. Ticket-level actions remain the only path for services with no stages defined (backward-compat) and for cross-cutting states likeno-showandtransferthat don't belong to a particular stage.
Transfer a ticket to another service
POST /api/v1/queue-tickets/{ticket}/transfer
What it does: Use this when a patient needs to be moved to a different service mid-flow (e.g. Reception decides this consultation patient actually needs Lasik). The original ticket's status is set to transferred. A brand-new ticket is created in the target service with a fresh ticket_number and a fresh stage flow — and a transferred_from_id pointing back so history is preserved.
Body:
{
"target_service_id": 9,
"transfer_priority_type": "first_priority",
"priority_type": 4,
"notes": "Needs lasik consultation"
}Validation:
target_service_id— optional integer, must exist inservices(omit to mark as transferred without a new destination)transfer_priority_type— required enum (the position the new ticket takes in the target queue)priority_type— required enum 1–4 (the same scale as registration)notes— optional string, max 1000
Returns 201 with the new ticket. The original ticket's status is set to transferred. The new ticket starts a fresh stage flow if the target service has stages; the original ticket's stage history is preserved via transferred_from_id.
Billing transactions on a ticket
Charge services to the ticket — consultation fees, lab work, surgery fees, anything the patient is being billed for during this visit.
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/queue-tickets/{ticket}/billing-transactions | List all charges already on the ticket |
| POST | /api/v1/queue-tickets/{ticket}/billing-transactions | Add a new charge |
| PUT | /api/v1/queue-tickets/{ticket}/billing-transactions/{transaction} | Edit an existing charge (price, quantity, notes) |
| DELETE | /api/v1/queue-tickets/{ticket}/billing-transactions/{transaction} | Remove a charge |
A ticket can have many billing rows.
Medicine transactions on a ticket
Dispense pharmacy items at the Billing/Pharmacy stage. Each ticket has at most one medicine Transaction with many TransactionItem rows underneath — the routes operate on individual items.
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/queue-tickets/{ticket}/medicine-transactions | Get the medicine transaction with its dispensed items |
| POST | /api/v1/queue-tickets/{ticket}/medicine-transactions | Add a medicine item (creates the transaction lazily on first call) |
| PUT | /api/v1/queue-tickets/{ticket}/medicine-transactions/{transactionItem} | Update an item (quantity, notes) |
| DELETE | /api/v1/queue-tickets/{ticket}/medicine-transactions/{transactionItem} | Remove an item |
Stage admin & flow API
Branch-level reads
GET /api/v1/branches/{branch}/services-with-stages
Returns every service in the branch the user can access, with each service's stages nested. Both layers are filtered by user attachment.
{
"message": "Services with stages retrieved successfully.",
"data": [
{
"id": 1,
"name": "Consultation",
"code": "CON",
"stages": [
{ "id": 11, "name": "Reception", "code": "REC", "position": 1, "is_initial": true, "is_terminal": false },
{ "id": 12, "name": "Refraction", "code": "REF", "position": 2, "is_initial": false, "is_terminal": false },
{ "id": 14, "name": "Billing", "code": "BIL", "position": 4, "is_initial": false, "is_terminal": false },
{ "id": 15, "name": "Checked Out","code": "OUT", "position": 5, "is_initial": false, "is_terminal": true }
]
}
]
}Note: a stage may appear in
servicesbut be missing fromservices[i].stageswhen the user has access to the service but not to that particular stage (typical for non-admin staff).
GET /api/v1/branches/{branch}/stages
Flat list of every stage in the branch the user can access. Useful for global "where is patient X?" lookups.
Stage CRUD (admin)
All require the service-stages.manage permission.
| Method | Path | Body |
|---|---|---|
| GET | /api/v1/branch-services/{service}/stages | — |
| POST | /api/v1/branch-services/{service}/stages | { name, code, position, is_initial?, is_terminal? } |
| GET | /api/v1/branch-services/{service}/stages/{stage} | — |
| PUT | /api/v1/branch-services/{service}/stages/{stage} | any subset of { name, code, position, is_initial, is_terminal } |
| DELETE | /api/v1/branch-services/{service}/stages/{stage} | — |
| PUT | /api/v1/branch-services/{service}/stages/reorder | { order: number[] } — array of stage IDs in the desired order |
| POST | /api/v1/branch-services/{service}/stages/{stage}/staff | { user_id: number } |
| DELETE | /api/v1/branch-services/{service}/stages/{stage}/staff/{user} | — |
Validation rules:
name— required, max 120 charscode— required, max 16 chars, unique per service (not globally). Free-form — no auto-uppercase on the API or UI.position— required integer ≥ 0is_initial— optional boolean, defaults tofalseis_terminal— optional boolean, defaults tofalse
reorder rewrites the position field for every stage in the submitted order, starting at 1. Ignores stages from other services if accidentally included.
is_initial invariant — every service that has stages must have exactly one is_initial=true. The backend enforces this transactionally:
- Creating the first stage of a service auto-promotes it to
is_initial=true. - Setting
is_initial=trueon a stage atomically clears the flag on all other stages of the same service. - Setting
is_initial=falseon the only initial stage auto-promotes the lowest-position remaining stage. - Deleting the initial stage auto-promotes the lowest-position remaining stage.
The frontend doesn't need to enforce any of this — just send the user's intent and the backend reconciles.
Per-stage queue (operations)
GET /api/v1/service-stages/{stage}/queue?date=YYYY-MM-DD
What it does: Returns the live work queue for a single stage station — the patients currently in line at Reception, or at Refraction, etc. This is the data behind a stage staffer's "what's next" screen.
Filtering and ordering:
- Statuses included:
waiting,called,in_service(the active three).completedandskippedare excluded. - Date defaults to today. Pass
?date=2026-05-08to inspect another day. - Ordered by
position(the per-stage-per-day sequence) thenentered_atfor stable tie-breaking.
The user must be attached to the stage via service_stage_user (or hold service-stages.manage) — returns 403 otherwise.
{
"message": "Stage queue retrieved successfully.",
"data": [
{
"id": 42,
"queue_ticket_id": 1001,
"service_stage_id": 11,
"status": "waiting",
"position": 1,
"entered_at": "2026-05-08T09:00:00Z",
"called_at": null,
"served_at": null,
"completed_at": null,
"called_by_user_id": null,
"served_by_user_id": null,
"notes": null,
"stage": { "id": 11, "name": "Reception", "code": "REC", "position": 1, "is_initial": true, "is_terminal": false }
}
],
"links": { ... },
"meta": { ... }
}Visit transitions (operations)
These are the buttons a stage staffer clicks while working their station. Each acts on a specific TicketStageVisit (a row in the per-stage queue), not the whole ticket. All four require the user to be attached to the visit's stage. All return the updated visit (with stage eager-loaded) on success.
| Method | Path | What it does | Status codes |
|---|---|---|---|
| PUT | /api/v1/ticket-stage-visits/{visit}/call | Calls the next waiting patient to the station. Records caller and time. | 200 / 403 / 409 (already called) |
| PUT | /api/v1/ticket-stage-visits/{visit}/serve | Patient arrived; staff is now serving. Records server and time. | 200 / 403 |
| PUT | /api/v1/ticket-stage-visits/{visit}/complete | Stage work is done. If this is a terminal stage, the parent ticket also flips to completed. | 200 / 403 |
| PUT | /api/v1/ticket-stage-visits/{visit}/skip | Patient skipped this stage (e.g. didn't need refraction this visit). | 200 / 403 |
409 conflict on call — only the call action is racey. The server uses an atomic compare-and-update: only succeeds if the visit is still waiting. If two staff click "Call next" simultaneously, the second request gets a 409 because the visit is no longer waiting. The UI should refresh the queue and show "Already called by another user" rather than retry.
Move ticket to another stage
POST /api/v1/queue-tickets/{queueTicket}/stages/{stage}
What it does: Moves the ticket to any stage of its service — forward, backward, or revisit. The patient's current stage visit is closed (its status becomes completed and completed_at is set) and a brand-new TicketStageVisit is inserted at the target stage with a fresh position from that stage's per-day counter. The ticket's current_stage_visit_id is updated to the new visit.
This is the same call regardless of direction — the model doesn't differentiate forward, backward, or revisit. Use it after Reception's complete to send the patient to Refraction; use it again after Refraction to send them to Billing; use it once more if the patient needs to go back to Refraction for a recheck.
User must be attached to the target stage (or hold service-stages.manage). The target stage must belong to the ticket's branch_service_id (returns 422 otherwise — you can't transfer-via-stage-move; use the transfer endpoint for cross-service moves).
Returns 201 with the new visit:
{
"message": "Ticket moved to stage.",
"data": { /* TicketStageVisit */ }
}Queue index — current_stage_visit field
The existing queue index endpoint (documented in the Queue Tickets API section above) now eager-loads current_stage_visit on every ticket so the queue board can show the patient's current stage in one fetch. The field is null for tickets in services that have no stages defined (backward-compatible) or for tickets that have completed all stages.
{
"data": [
{
"id": 1001,
"ticket_number": "CON-001",
"status": "checked_in",
"current_stage_visit_id": 42,
"current_stage_visit": {
"id": 42,
"service_stage_id": 11,
"status": "waiting",
"position": 1,
"stage": { "id": 11, "code": "REC", "name": "Reception", "position": 1, "is_initial": true, "is_terminal": false }
}
}
]
}Common workflows
Receptionist — register a patient
- POST to existing
branch-services/{branchService}/queuewithvisit_id,priority_type, etc. - Backend creates the ticket AND auto-enters the stage marked
is_initial=truefor that service (if any). The receptionist does not need to be attached to the initial stage — registration uses a system-actor that bypasses the attachment check. - Response includes
current_stage_visitpointing to the newly-created entry-stage visit.
No additional client call needed. Note: there are no longer check_in / check_out flags on BranchService — gating is purely via stage attachments and the is_initial / is_terminal flags.
Stage staff — work the queue at a station
- Load the stage list for the current service:
GET /branches/{branch}/services-with-stages. Render each stage the user can see as a tab. - On tab open, fetch its queue:
GET /service-stages/{stage}/queue. Refresh on a 5–10s interval (or via realtime push if available). - Click "Call next":
PUT /ticket-stage-visits/{visit}/callon the firstwaitingvisit. Handle 409 by refreshing the list. - Patient arrives at counter — click "Serve":
PUT /ticket-stage-visits/{visit}/serve. - Done with patient — two options:
- Complete + advance (typical):
PUT /ticket-stage-visits/{visit}/complete, thenPOST /queue-tickets/{ticket}/stages/{nextStage}. There is currently no atomic combined endpoint — the two requests are sequential. If you need atomicity, see the "Open follow-ups" section. - Complete only (terminal stage): just
PUT /complete. The ticket auto-flips tocompleted.
- Complete + advance (typical):
Skip a stage
PUT /ticket-stage-visits/{visit}/skip marks the current visit as skipped without advancing. Then POST /queue-tickets/{ticket}/stages/{stage} to move to whichever stage is next.
Send a patient back
POST /queue-tickets/{ticket}/stages/{previousStage} — the same endpoint as forward movement. It closes the current visit (regardless of status) and creates a fresh visit at the target. The position reflects the patient's new arrival time at that stage.
Revisit history
current_stage_visit shows where the ticket is now. The full visit history is on the ticket via the stageVisits relation. The backend doesn't currently expose a dedicated "ticket history" endpoint — fetch the ticket with its visits via the queue index endpoint and read stageVisits if eager-loaded.
If you need richer ticket-level history (e.g. a timeline view), file a backend follow-up — it's a small addition.
Admin — configure stages for a service
- List existing:
GET /branch-services/{service}/stages. - Create one:
POSTwith{ name, code, position, is_initial?, is_terminal? }. Mark the entry stage withis_initial: trueand the completion stage withis_terminal: true. (The first stage in an empty service is auto-promoted tois_initial=true.) - Reorder:
PUT /branch-services/{service}/stages/reorderwith the new ID order. Reordering does not change theis_initialflag — the entry point stays whichever stage was explicitly marked. - Assign staff:
POST /branch-services/{service}/stages/{stage}/staffwith{ user_id }. Detach withDELETE .../staff/{user}.
Stage deletion fails (DB-level restrictOnDelete) when ticket visits exist referencing the stage. The 500 from the resulting QueryException should be caught and shown as "stage in use" — surface a friendlier error in the UI. The Inertia variant of the controller (used by the Laravel admin panel) handles this gracefully and returns a flash error.
Suggested state structure
- Stage admin (CRUD) — keep stages in a Pinia store keyed by
branchServiceIdso the admin screen can drill into a service without re-fetching. Reorder optimistically on the client and let the server response confirm. - Operational stage queue (the auto-refreshing list of active visits at one stage) — prefer a composable scoped to the queue tab. The data is short-lived and shouldn't pollute global state. Refresh on a 5–10s interval; clear the interval on unmount.
- Active queue board (the cross-stage list of tickets for a service) — store this globally. Use the
current_stage_visitfield on each ticket to render which stage it's on without an extra fetch.
See state-management/pinia.md for the project's store conventions.
Error handling
| Status | Meaning | UI response |
|---|---|---|
| 401 | Token missing or expired | Redirect to login |
| 403 | User not attached to stage / lacks permission | Toast: "You don't have access to this action" |
| 404 | Stage doesn't belong to that service / not found | Toast: "Stage not found" — likely stale UI state, refresh |
| 409 | Concurrent call on the same waiting visit | Toast: "Already called by another user" — refresh queue |
| 422 | Validation error or cross-service stage move | Show field errors from errors payload |
| 500 | Stage deletion blocked by FK (visits exist) | Toast: "Cannot delete a stage that has visits" |
Open follow-ups
These are known gaps the backend hasn't shipped yet. Coordinate with backend before designing UI that depends on them:
- Atomic complete-and-advance. The spec defined
POST /api/v1/ticket-stage-visits/{visit}/complete-and-advancefor one-call complete + move-to-next. It's not implemented. Until it ships, do the two calls sequentially and accept a small race window where another user could move the ticket between requests. - Ticket history endpoint. No dedicated endpoint for "show me everything that happened to ticket X". Pull the ticket from the queue index and read
stageVisits(when present). If the eager-load isn't there, raise a backend ask. - Realtime updates. The stage queue is currently poll-only. Push (Echo / Pusher) integration is not in scope for this feature.
- Soft-delete on stages. Hard delete only. Once a stage has visits, you cannot delete it without cascading. Plan stage taxonomy carefully before going to production.
