busy-office-ui
Menu

Pattern: inbox

Who uses it: every ERP user, first thing — the screen a working day starts on. How often: continuously; it is the tab that stays open. What "done" looks like: the list is empty of things that need you — every row either acted on or explicitly handed off. An inbox is cross-type and cross-process: approvals, exceptions, assigned tasks and failed jobs in ONE list, and every row says why it is yours. Not for one document type's approval queue — that scoped, single-process view is the approval workflow's job; and not a feed of things that merely happened — that is notification's contract. The inbox holds only rows that block on a person.

Routine approvals may decide in place. A single-step, unflagged approval row can expand to a compact preview — header facts, line summary, attachments — with Approve/Reject right there, running through approval's own dialog and endpoints, never a second copy of them. Anything flagged, mid-chain, high-value, or not an approval keeps the plain link-out: a preview that can't show the real reason — an exception's diff, a job's log — is worse than the row it would replace.

The screen

Items needing your action
Item Why it's yours Waiting Action
PO PO-88213 · Acme Supply Co. · $18,940.50 Awaiting your approval — step 3 of 4 2 days Review
INV INV-10236 · Initech GmbH · $730.25 Exception price variance 12% over tolerance 26 days Open
JOB Nightly GR/IR reconciliation Exception failed at 02:14 — assigned to you 6 hours Open log
PO PO-88219 · Globex Industrial · $2,110.00 Awaiting your approval — step 1 of 2 4 hours Review
TASK Confirm vendor bank change · Stark Components Assigned to you by J. Kim 1 day Open
REQ Requisition R-4471 · Warehouse consumables Awaiting your approval — final step 3 days
Requested by
T. Alvarez, Warehouse
Cost centre
CC-3405
Amount
1,240.00 USD

3 lines — nitrile gloves, air filters, degreaser · vendor-quote.pdf (opens in a viewer, not inline — a pane can't satisfy diligence on a scanned document)

Open full record

Every row answers why is this mine in its own column — awaiting your approval, an exception assigned to you, a task handed to you — because a cross-type list without that column is just a worklist someone else has to explain. The 26-day row is deliberately old: age is the monitoring signal, and it must be visible without opening anything. PO-88213 stays link-out on purpose — deep in a 4-step chain and $18,940.50, it is exactly the case the ERP-practice review flagged for escalation, not a row we simply didn't get to. R-4471, above, is the opposite case — one step, one approver, modest value — shown expanded for illustration; the decision dialog itself is not in this static demo, same as approval's own (data-dialog-trigger needs initDialogs(), and the toggle above needs its own small script to flip aria-expanded — this page has none, so both render permanently open here).

On a phone — one column, one action per row

Item Waiting Source
Approval PO-88213 · $48,200 3d Purchasing
Exception GR-4471 short-shipped 1d Warehouse
Task Submit week 34 6h HR

Same table, same rows — the column priority ladder does the narrowing: Source (--tertiary) goes first, then Waiting (--secondary), and what survives is the one thing a phone reader needs — what it is and what it is about, as a link. Expand-in-place does not come along: a preview inside a 390px row is a worse version of the screen it previews, so a phone row links out to the decision screen, which has its own phone section. Bulk claim is deliberately absent for the same reason approval drops its bulk queue. Not a mobile pattern — one inbox, read narrow.

Markup

<!-- One list, many sources. The row's TYPE is a badge; the row's
     REASON is a column. Filter chips are a segmented control whose
     counts come from the server. -->
<div class="bo-segmented" role="group" aria-label="Inbox filter">
  <input class="bo-segmented__input bo-visually-hidden" type="radio"
         name="inb-f" id="inb-all" checked>
  <label class="bo-segmented__option" for="inb-all">All (6)</label>
  <!-- …one radio+label per row source -->
</div>

<table class="bo-data-table">
  <thead><tr>
    <th scope="col">Item</th>
    <th scope="col">Why it's yours</th>
    <th scope="col">Waiting</th>
    <th scope="col"><span class="bo-visually-hidden">Action</span></th>
  </tr></thead>
  <tbody>
    <tr>
      <td><span class="bo-badge bo-badge--type">PO</span> PO-88213 …</td>
      <td>Awaiting <strong>your approval</strong> — step 3 of 4</td>
      <td>2 days</td>
      <td><a class="bo-btn bo-btn--sm bo-btn--secondary"
             href="/approve/PO-88213">Review</a></td>
    </tr>
  </tbody>
</table>

Anatomy

  1. Filter chips — a segmented control, one option per row source, counts rendered server-side. Native radios, so arrow keys move between filters for free.
  2. The list — a plain data table; each row's type is a badge, exceptions carry the danger tone plus the word (two-channel).
  3. "Why it's yours" column — the inbox's defining part: awaiting your approval / assigned to you / exception routed to you. Bold on the phrase that names the reader.
  4. Waiting column — age since the row became yours. This is the human-monitoring signal: an old row is visible shame, unopened.
  5. Per-row action — one secondary button linking into the screen that resolves the row (approval, object page, log). The inbox does not resolve rows itself, with one named exception below — everything else is still a link out, never a second decision surface.
  6. Expand-in-place preview — a routine approval row only: a toggle button (aria-expanded / aria-controls) reveals key-value facts plus a line/attachment summary, with Approve/Reject wired to approval's own decision dialog and endpoints — not a new dialog, not a new endpoint. Flagged, mid-chain, high-value, and every non-approval row keep the plain link-out; there is no generic "detail panel" here, because the four row types share no common shape to hang one on.

Data contract

RequestReturns / on failure
GET /inbox?filter=approvalsthe tbody fragment + refreshed chip counts; an agent reads the same rows the human sees — data-item-id, data-reason, data-age-hours per row make it machine-readable without scraping prose
POST /inbox/:id/claimthe row re-rendered as claimed (byline: who); 409 if someone claimed it first — re-render with the claimer's name, never silently drop
resolutionhappens on the TARGET screen (approve on the approval screen, fix on the object page); the inbox row disappears on the next fetch — the inbox never posts a decision itself, with one exception below
POST /req/:id/approve
POST /req/:id/reject
the one exception: a routine, single-remaining-step approval row's expanded preview posts here directly — this is approval's own decision contract, reused verbatim (reject's reason is REQUIRED server-side, 409 if already decided → re-render naming who decided), reached through approval's own dialog. No new inbox-owned decision endpoint exists for any row type

The AI-manages / human-monitors split, stated: an agent works this screen through the row data attributes and the claim/resolve endpoints; the human monitors through the chip counts, the danger badges, and the Waiting column. Same rows, two channels — neither is decoration.

States

StateWhat ships
Nothing needs youa success, and it must look like one — "Inbox zero · nothing is waiting on you" with a positive tone, never the same grey empty-box as a filter mismatch
Filtered to emptythe DIFFERENT empty: "No exceptions right now — 4 items under other filters", with the chips still showing their counts so the reader knows where the work went
New rows arrivedchip counts update on the next poll; rows the reader has scrolled past are never reordered under them mid-read
Claimed by a colleague409 on claim → the row re-renders naming the claimer; it leaves the list on the next fetch
Row's document deletedthe row states it plainly ("PO-88213 no longer exists") with a dismiss — a dead link that 404s is a support call
Overduethe Waiting cell past a threshold gains the warning tone + the word "overdue" — two channels, and the age was already visible before the threshold
Approval row, expandedheader facts, line summary, and an attachment list render inline; the attachment itself never does — it opens in a dedicated viewer, since a preview pane can't carry the diligence a scanned document or PDF needs
Approval row, escalatedflagged, mid-chain, or high-value stays a plain link-out row — no preview button at all, so there is nothing that looks decidable but isn't
Decision submitted from the previewsame as approval's own: dialog buttons disabled until the swap lands, so a failed note is never lost; success re-renders the row gone (it resolved) or, on 409, re-renders the preview naming who decided first
On a phonethe priority ladder leaves item + link; expand-in-place is not offered at all, so no row looks decidable in place when it isn't — the row links out to approval's phone decision card
No JSthe base list is fully functional with no JS dependency — filter chips are native radios, rows are a plain table, every link-out action is an ordinary link, and the polling/claim/409 behaviour above is the server's job, not client script. Stated honestly, not papered over: the expand-in-place toggle (aria-expanded) and the decision dialog both need JS, the same as approval's own dialog does on its own page — without it, a routine row simply keeps its plain link-out, same as every other row

Components used

complexity 2 of 4

↑↓ move Enter open Esc close