Service ticket lifecycle
A service ticket moves through a small set of statuses. Each transition is logged to service_ticket_status_history and audited.
The workflow has a step the v1 status set didn't: after work is complete, the shop calls or texts the customer to tell them the bike is ready. That's a distinct state from "ready but customer doesn't know yet" — important operationally because the owner can see who hasn't been reached yet. Code-side, 'ready' keeps the same string so existing transition-map JS keeps compiling; only the display label is now 'Completed'. The new state is 'notified', slotted between ready (Completed) and picked_up.
invoice state inserted between Notified and Picked Up (2026-05-25)Two refinements landed: migration 061 updates the awaiting_parts display name to "Parts - Hold" (operator request — the new label reads as the actionable state, not a passive observation). Code stays 'awaiting_parts' so transition maps keep compiling. Migration 067 inserts a new invoice status (display 'Invoice', display_order=55, amber #f59e0b) between notified and picked_up — the missing "customer is ringing it up but the bike is still in the shop" moment. The auto-park trigger moves from picked_up → invoice; clicking Invoice on the Slot switches to the Sales tab where the auto-created parked sale is waiting. Invoiced ≠ finished — the Slot goes read-only on invoice to enforce the doctrine (money flowing at the till; bike still on the floor). Picked Up stays the terminal "bike out the door" state.
States and transitions
State definitions
| State (code) | UI label | What it means |
|---|---|---|
dropped_off | Dropped Off | Customer left the bike; ticket is awaiting work |
quoted | Quoted | Mechanic provided a quote; awaiting customer approval (optional state) |
in_progress | In Progress | A mechanic is actively working on the bike |
awaiting_parts | Parts - Hold | Work is on hold waiting for parts to arrive (label updated in migration 061; code stays 'awaiting_parts') |
ready | Completed | Work is complete; customer has not yet been told |
notified | Notified | Customer has been called or texted; bike is awaiting pickup (teal #14b8a6) |
invoice | Invoice | Customer is at the till ringing it up; parked sale auto-created on the Sales ledger; Slot is read-only. Bike is still in the shop (amber #f59e0b) (migration 067) |
picked_up | Picked Up | Customer paid and took the bike; terminal |
cancelled | Cancelled | Ticket was abandoned or refused; terminal |
Why ready keeps its code name. Migration 056 only changed the display_name column — the status code is still 'ready' so every transition-map / state-machine / audit-event-name in src/index.js and public/index.html keeps working without a rename. Operators see Completed; code reads ready.
Transition rules
- Every transition requires a staff
id(no system-initiated transitions during normal flow; cron may flag abandoned tickets but doesn't move them) dropped_off→quotedis optional; many tickets skip directly toin_progressawaiting_partsis a sub-state of "in flight"; counts as work-in-progress on the kanbanready→in_progressis the "reopen" path; common when a customer test-rides and reports an issuepicked_upandcancelledare terminal; only an audit-correction admin action can change them after the fact
Side effects of transitions
| Transition | Side effects |
|---|---|
* → in_progress | If assigned_staff_id is null, set it to the transitioning staff |
* → ready (Completed) | Trigger optional SMS to customer (if opted-in); write to service_ticket_messages regardless. Also upserts a Service parked sale on the Sales ledger via POST /api/parked-sales/upsert-by-ticket (deduped by ticket_id) so the cashier has a row to resume when the customer comes to pay — see multi-user realtime |
notified → invoice | Auto-park trigger fires (parked sale upserted if not already there); operator is switched to the Sales tab; Slot goes read-only (the parked sale is the source of truth for the lines while it's at the till) |
invoice → picked_up | Triggered by apiTicketsInvoice server-side when the parked sale is consumed; transaction is linked via service_tickets.transaction_id |
invoice → notified | When the cashier cancels the parked sale on the Sales side, the ticket flips back from Invoice and the Slot's read-only lock clears |
in_progress → cancelled | Release any committed parts inventory (qty_committed → 0); optional refund of pre-paid amounts |
awaiting_parts → in_progress | No side effects; the inventory state already updates as the part arrives |
Late-status detection
A daily cron flags tickets that have been in in_progress, awaiting_parts, or ready longer than the per-status SLA (configurable in shop_config). Surfaces on the Today dashboard as "Late tickets" card.
Default SLAs:
in_progress> 3 days: warningawaiting_parts> 7 days: warning (parts on order — owner may need to follow up with vendor)ready> 5 days: warning (customer hasn't picked up — send a reminder SMS)
Audit
Every transition writes:
service_ticket_status_historyrow:{ticket_id, from_status, to_status, staff_id, at, notes?}audit_eventsrow:action='ticket.status',entity_id=ticket_idaudit_mutationsrow: before/after of theservice_ticketsrow