busy-office-ui
Menu

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)

  1. Completed: Header
  2. Completed: Line items
  3. 3Approvers
  4. 4Not started: Review

Status timeline (incl. rejected) + audit trail

PO-88213 status

  1. Completed: Submitted
    R. Vance ·
  2. Completed: Manager approval
    J. Kim ·
  3. Cost-center approval
    waiting on M. Okafor · 2 days
  4. Rejected: Finance release
    returned by A. Chen · budget hold

Audit trail

  1. R. Vance submitted PO-88213
  2. J. Kim approved "Within Q3 budget."
  3. 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

PO-88213 · Northwind Supply Pending
Amount
$48,200.00
Requested by
M. Osei
Waiting since
3 days
Budget after
$11,800 left

Open the full request →

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

  1. Record summary (not in this demo — see record-detail for the shape) — enough to decide without leaving: amount, vendor, cost centre.
  2. Status timeline — the chain so far, with the current step marked aria-current="step".
  3. Decision cluster (not in this demo) — Approve / Reject; Reject requires a reason, so both open the dialog.
  4. Dialog (not in this demo — same shape as master-detail's edit dialog) — the note (rich text) plus optional extra approvers.
  5. 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

RequestReturns / on failure
POST /po/:id/approvethe updated timeline + audit fragment
POST /po/:id/rejectsame, with the reason recorded; reason is REQUIRED server-side, not just in the UI
any decision on a decided record409 → re-render the timeline as it now is, with a banner naming who decided and when

States

StateWhat ships
Awaiting your decisionbuttons visible and enabled — this is the only state where they appear
Awaiting someone elsethe 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
Submittingdialog buttons disabled; the dialog stays open until the swap lands so a failure keeps the typed note
Not an approverthe screen is read-only; approval controls are absent, not disabled
Delegated / out of officename the delegate in the timeline — an approval chain that silently reroutes is an audit problem
On a phonethe 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 tabthe 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 JSfully 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

↑↓ move Enter open Esc close