Pattern: approval workflow
Who uses it: an approver named in the chain — a manager,
a cost-centre owner, finance. How often: whenever a
document reaches their step, which for most approvers is a handful of
times a week. What "done" looks like: the decision
recorded, the next person notified, nothing silently stuck. The composed
slice-3 screen: wizard stepper, status timeline, approval queue (the
data-table pattern you already know), and the audit trail. State always
has two channels: a visible non-color cue (the glyph) and
programmatic state (visually-hidden text / aria-current) —
one without the other is a defect.
Not for a record with no decision to make — a plain edit to a
record's own fields, with nobody else's sign-off in the way, is
record-detail's job; reach for
this pattern only when a chain of people, not just the owner, has to act
before the record can move.
Wizard stepper (document creation)
- Completed: Header
- Completed: Line items
- 3Approvers
- 4Not started: Review
Status timeline (incl. rejected) + audit trail
PO-88213 status
- Completed: Submitted
- Completed: Manager approval
- Cost-center approval
- Rejected: Finance release
Audit trail
- R. Vance submitted PO-88213
- J. Kim approved "Within Q3 budget."
- System reminded M. Okafor
Approval queue
The queue is the standard selectable data table with bulk Approve/Reject — see the list-report pattern; nothing new to learn.
On a phone — the decision, not the document
An approver on a phone is deciding, not
reading. Ship the three facts the decision turns on —
what, how much, and what it leaves — plus a link to everything else,
and put the decision within thumb reach: the same
--bar the RF
screens use, docked by the consumer's own sticky line. What does NOT
survive the trip: the audit trail (link it), the bulk queue (deciding
twenty things by thumb is how rubber-stamping happens), and the
rejection note as a typed essay — offer reasons as buttons and let
the essay be optional. Not a second pattern: it is
this pattern at 390px, so the routes, the contract and the 409 story
below are unchanged.
Markup — timeline
<!-- State has TWO channels, always: the visible glyph (aria-hidden)
AND visually-hidden state text. role="list" restores list semantics
that list-style:none strips in some AT. -->
<ol class="bo-timeline" role="list">
<li class="bo-timeline__step" data-state="done">
<span class="bo-timeline__marker" aria-hidden="true">✓</span>
<div>
<div class="bo-timeline__title">
<span class="bo-visually-hidden">Completed: </span>Submitted
</div>
<div class="bo-timeline__meta">R. Vance ·
<time datetime="2026-08-10T09:14">2026-08-10 09:14</time></div>
</div>
</li>
<li class="bo-timeline__step" data-state="current" aria-current="step">
<span class="bo-timeline__marker" aria-hidden="true">●</span>
<div>
<div class="bo-timeline__title">Cost-center approval</div>
<div class="bo-timeline__meta">waiting on M. Okafor</div>
</div>
</li>
<li class="bo-timeline__step" data-state="rejected">
<span class="bo-timeline__marker" aria-hidden="true">✕</span>
<div>
<div class="bo-timeline__title">
<span class="bo-visually-hidden">Rejected: </span>Finance release
</div>
</div>
</li>
<li class="bo-timeline__step" data-state="pending">
<span class="bo-timeline__marker" aria-hidden="true">4</span>
<div>
<div class="bo-timeline__title">
<span class="bo-visually-hidden">Not started: </span>Archive
</div>
</div>
</li>
</ol> Anatomy
- Record summary (not in this demo — see record-detail for the shape) — enough to decide without leaving: amount, vendor, cost centre.
- Status timeline — the chain so far, with the current step marked
aria-current="step". - Decision cluster (not in this demo) — Approve / Reject; Reject requires a reason, so both open the dialog.
- Dialog (not in this demo — same shape as master-detail's edit dialog) — the note (rich text) plus optional extra approvers.
- Audit trail — every decision with actor, timestamp and note; append-only.
Follows the review-screen anatomy contract, spent on the moment where the reader must decide rather than just read — see that page for the other two shapes.
Data contract
| Request | Returns / on failure |
|---|---|
| POST /po/:id/approve | the updated timeline + audit fragment |
| POST /po/:id/reject | same, with the reason recorded; reason is REQUIRED server-side, not just in the UI |
| any decision on a decided record | 409 → re-render the timeline as it now is, with a banner naming who decided and when |
States
| State | What ships |
|---|---|
| Awaiting your decision | buttons visible and enabled — this is the only state where they appear |
| Awaiting someone else | the timeline shows whose turn it is; no buttons for you |
| Already decided (409) | never silently overwrite — show the decision that won and let the user re-read |
| Submitting | dialog buttons disabled; the dialog stays open until the swap lands so a failure keeps the typed note |
| Not an approver | the screen is read-only; approval controls are absent, not disabled |
| Delegated / out of office | name the delegate in the timeline — an approval chain that silently reroutes is an audit problem |
| On a phone | the decision card + a docked --bar; the audit trail and line items become links, not collapsed sections — a collapsed audit trail on a phone is read by nobody and still costs the scroll |
| Decided on a phone, stale in a tab | the same 409 as anywhere: the desktop tab that still shows buttons must lose, and say who won — a phone decision is not a lesser one |
| No JS | fully functional — this page has no JS dependency; the stepper, status timeline and audit trail are all plain lists with no hidden state to reveal. The decision dialog and Approve/Reject buttons are not part of this static demo (see the note above); in a real build those would be an ordinary <form> POST, which also needs no JS to submit |
Components used
complexity 3 of 4