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
| 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 | |
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
- Filter chips — a segmented control, one option per row source, counts rendered server-side. Native radios, so arrow keys move between filters for free.
- The list — a plain data table; each row's type is a badge, exceptions carry the danger tone plus the word (two-channel).
- "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.
- Waiting column — age since the row became yours. This is the human-monitoring signal: an old row is visible shame, unopened.
- 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.
- 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
| Request | Returns / on failure |
|---|---|
| GET /inbox?filter=approvals | the 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/claim | the row re-rendered as claimed (byline: who); 409 if someone claimed it first — re-render with the claimer's name, never silently drop |
| resolution | happens 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
| State | What ships |
|---|---|
| Nothing needs you | a 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 empty | the 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 arrived | chip counts update on the next poll; rows the reader has scrolled past are never reordered under them mid-read |
| Claimed by a colleague | 409 on claim → the row re-renders naming the claimer; it leaves the list on the next fetch |
| Row's document deleted | the row states it plainly ("PO-88213 no longer exists") with a dismiss — a dead link that 404s is a support call |
| Overdue | the 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, expanded | header 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, escalated | flagged, 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 preview | same 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 phone | the 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 JS | the 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