The 7-state work-order playbook we shipped (and why)
OPEN → ASSIGNED → IN_PROGRESS → DONE with two side states for ON_HOLD and CANCELED. The state choice determines the whole shop's mental model — here's why we landed where we did.
The number of states you give a work order is one of those quiet decisions that defines the rest of the product. Too few and managers can't see what's actually happening; too many and mechanics game the system to skip a step. We landed on six core states + one terminal cancellation. Here's the full play.
The state machine
OPEN ─┬─→ ASSIGNED ─→ IN_PROGRESS ─→ DONE
│ │ │
│ ↓ ↓
└────→ ON_HOLD ←───────┤
│ │
└──→ CANCELED ←┘Why six (not three)
The minimum viable WO is OPEN → IN_PROGRESS → DONE. We tried that. Two failure modes immediately:
- No ASSIGNED state. Managers couldn't see the difference between “we're aware” and “a tech is on it.” The whole queue looked the same.
- No ON_HOLD state. A WO waiting for a part had to either stay IN_PROGRESS (lying) or close (premature). Mechanics started closing them and reopening when parts came in, which destroyed the timing data.
ASSIGNED solves the first. ON_HOLD solves the second. CANCELED is for “turns out the unit was fine” — distinct from DONE because the DONE bucket feeds cost-per-asset analytics and abandoned work shouldn't.
Closure rules
DONE and CANCELED are terminal in the sense that they write a closedAt + closedById. Going backwards (reopening a DONE WO) is allowed but rare; we keep the original closure stamps and start a fresh in-progress timer.
DONE has one extra side effect: if the WO was spawned from a Flag, marking DONE auto-resolves the linked flag. This was non-obvious — early users were closing WOs without resolving the flag, and the dashboard kept showing the asset as broken. One transaction, two updates, the right outcome.
Priority is orthogonal to status
Don't encode urgency in the status enum. URGENT / HIGH / NORMAL / LOW lives in a separate priority field; the queue sorts by status THEN priority THEN due date. This lets you have an URGENT OPEN ticket that's competing with a NORMAL IN_PROGRESS ticket — and the manager sees both.
Per-org WO numbers
Each org has its own WO-1, WO-2, … sequence. Not a global UUID, not the database row id. Mechanics say “close out 1847” in chat, and the next person knows what they mean.
Race condition watch: two managers creating WOs in the same millisecond can collide on the next-number lookup. We retry on Prisma P2002 unique-violation up to 5 times. Five attempts is plenty — no fleet creates 5 WOs in the same millisecond.
What we deliberately left out
- Sub-tasks / dependencies. Asana-style subtask trees would let you decompose a hydraulic pump replacement. Mechanics don't want to manage trees; they want a notes field.
- Custom statuses per org. Tempting and expensive — the moment one org has “PARTS_ORDERED” and another has “WAITING_VENDOR” you can't build a cross-org dashboard or write a coherent help doc. ON_HOLD with a free-text note covers the use case at 90% of the cost.
- Approval workflows. Not v1. When a customer asks for “manager has to approve before status flips to DONE,” we wire it as a per-org config flag, not a new status.
The code
lib/work-orders.ts for the state machine, app/work-orders for the UI, andapp/api/work-orders/[id] for the PATCH endpoint that handles status transitions + flag auto-resolution.