Skip to content

Last updated:

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 ticket

Key invariants:

  • A ticket's branch_service_id does 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 be CON-001 overall but #3 in 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 with is_terminal=true (completion points). Registration auto-enters the is_initial stage. Completing a visit on a stage with is_terminal=true flips the parent ticket's status to completed.

Visibility rules

The frontend sees only what the API exposes. Server-side scopes filter every list endpoint.

User has access toSource of truth
Branchesusers.default_branch_id and branch staff
Services in the branchbranch_service_user pivot
Stages in a serviceservice_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.

ValueMeaning
waitingIn the queue, not yet called
calledStaff called the patient to a counter
in_servicePatient is currently being served at the counter
completedWhole ticket finished. Auto-set when a is_terminal stage visit completes
transferredTicket was sent to another service; a new ticket was created
skippedPatient was skipped (didn't respond when called)
no_showPatient did not arrive
checked_inInitial state assigned when registering — the ticket exists and is queued
checked_outPatient 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.

ValueMeaning
waitingPatient is in line at this stage
calledStage staff called them to the station
in_serviceStage staff is actively serving them
completedStage work is done. Either a manual complete, or auto-closed when the patient moves to the next stage
skippedStage 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.

ValueLabelWhen to use
1EmergencyWalk-in emergencies — front of queue
2PWDPersons with disabilities
3SeniorSenior citizens
4RegularDefault

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.

MethodPathWhat it does
POST/api/v1/visitsOpen 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}/completeClose 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:

json
{
  "patient_id": 123,
  "visit_id": 456,
  "priority_type": 4,
  "is_appointment": false,
  "appointment_id": null,
  "notes": null
}

Validation:

  • patient_id — required, must exist in patients
  • visit_id — required, must exist in patient_visits
  • priority_type — required, integer 1–4 (Emergency / PWD / Senior / Regular)
  • is_appointment — optional boolean, default false
  • appointment_id — required when is_appointment=true; must exist in surgery_schedules
  • notes — 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):

json
{
  "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, default 15
  • search — matches against ticket_number and patient first/last/full name
  • servicebranch_service_id to narrow the list (useful when a user is attached to multiple services)
  • dateYYYY-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.

MethodPathWhat it does
PUT/api/v1/queue-tickets/{ticket}/callMarks the ticket as called to a counter. Records who called (called_by_user_id) and when (called_at).
PUT/api/v1/queue-tickets/{ticket}/servePatient has arrived at the counter; staff is now serving them. Records who served and when.
PUT/api/v1/queue-tickets/{ticket}/completeMarks the ticket finished. Stops it from appearing in active queue lists.
PUT/api/v1/queue-tickets/{ticket}/skipPatient didn't respond when called. Pushes them down the queue (the server bumps registered_at to "now").
PUT/api/v1/queue-tickets/{ticket}/no-showPatient didn't arrive at all. Removes them from the active queue without completing them.
POST/api/v1/queue-tickets/{ticket}/transferSends 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 like no-show and transfer that 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:

json
{
  "target_service_id": 9,
  "transfer_priority_type": "first_priority",
  "priority_type": 4,
  "notes": "Needs lasik consultation"
}

Validation:

  • target_service_id — optional integer, must exist in services (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.

MethodPathWhat it does
GET/api/v1/queue-tickets/{ticket}/billing-transactionsList all charges already on the ticket
POST/api/v1/queue-tickets/{ticket}/billing-transactionsAdd 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.

MethodPathWhat it does
GET/api/v1/queue-tickets/{ticket}/medicine-transactionsGet the medicine transaction with its dispensed items
POST/api/v1/queue-tickets/{ticket}/medicine-transactionsAdd 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.

json
{
  "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 services but be missing from services[i].stages when 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.

MethodPathBody
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 chars
  • code — required, max 16 chars, unique per service (not globally). Free-form — no auto-uppercase on the API or UI.
  • position — required integer ≥ 0
  • is_initial — optional boolean, defaults to false
  • is_terminal — optional boolean, defaults to false

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=true on a stage atomically clears the flag on all other stages of the same service.
  • Setting is_initial=false on 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). completed and skipped are excluded.
  • Date defaults to today. Pass ?date=2026-05-08 to inspect another day.
  • Ordered by position (the per-stage-per-day sequence) then entered_at for stable tie-breaking.

The user must be attached to the stage via service_stage_user (or hold service-stages.manage) — returns 403 otherwise.

json
{
  "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.

MethodPathWhat it doesStatus codes
PUT/api/v1/ticket-stage-visits/{visit}/callCalls the next waiting patient to the station. Records caller and time.200 / 403 / 409 (already called)
PUT/api/v1/ticket-stage-visits/{visit}/servePatient arrived; staff is now serving. Records server and time.200 / 403
PUT/api/v1/ticket-stage-visits/{visit}/completeStage 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}/skipPatient 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:

json
{
  "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.

json
{
  "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

  1. POST to existing branch-services/{branchService}/queue with visit_id, priority_type, etc.
  2. Backend creates the ticket AND auto-enters the stage marked is_initial=true for 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.
  3. Response includes current_stage_visit pointing 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

  1. Load the stage list for the current service: GET /branches/{branch}/services-with-stages. Render each stage the user can see as a tab.
  2. On tab open, fetch its queue: GET /service-stages/{stage}/queue. Refresh on a 5–10s interval (or via realtime push if available).
  3. Click "Call next": PUT /ticket-stage-visits/{visit}/call on the first waiting visit. Handle 409 by refreshing the list.
  4. Patient arrives at counter — click "Serve": PUT /ticket-stage-visits/{visit}/serve.
  5. Done with patient — two options:
    • Complete + advance (typical): PUT /ticket-stage-visits/{visit}/complete, then POST /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 to completed.

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

  1. List existing: GET /branch-services/{service}/stages.
  2. Create one: POST with { name, code, position, is_initial?, is_terminal? }. Mark the entry stage with is_initial: true and the completion stage with is_terminal: true. (The first stage in an empty service is auto-promoted to is_initial=true.)
  3. Reorder: PUT /branch-services/{service}/stages/reorder with the new ID order. Reordering does not change the is_initial flag — the entry point stays whichever stage was explicitly marked.
  4. Assign staff: POST /branch-services/{service}/stages/{stage}/staff with { user_id }. Detach with DELETE .../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 branchServiceId so 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_visit field 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

StatusMeaningUI response
401Token missing or expiredRedirect to login
403User not attached to stage / lacks permissionToast: "You don't have access to this action"
404Stage doesn't belong to that service / not foundToast: "Stage not found" — likely stale UI state, refresh
409Concurrent call on the same waiting visitToast: "Already called by another user" — refresh queue
422Validation error or cross-service stage moveShow field errors from errors payload
500Stage 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-advance for 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.

CPR - Clinical Patient Records