List summary cards — design
A strip of KPI cards above a list page, so a reader sees “how much is waiting, owed or late” before reading the rows. Sales and purchase orders (the generic trade lists) already have one; this spec covers the shared pattern and the next pages that get it.
Problem
Section titled “Problem”Claims, advances and petty cash open with cards (pending / approved / paid, count and value). Most
other transactional lists open straight onto a table: an AP clerk cannot see what is overdue without
sorting, an approver cannot see how long the oldest item has waited. Each page that wants cards today
would hand-roll its own StatCard row, so the wording, tones and grid drift.
As built so far
Section titled “As built so far”components/workflow/SummaryStrip.tsx: the generic strip. A page passesrows, anamountaccessor and itscards({key, tone, icon, match});semanticCards()is the default set (Draft / Pending / Active / Closed-rejected, plus an All card), each with a count and a base-currency total, computed from rows already loaded. Wired intotrade/DocumentsPage(all eight document kinds) andpayroll/RunsPage(Draft, Pending, Approved, Paid, matched on run status). Buckets are keyed on workflow semantic, never a state key (F9), so a tenant’s renamed or extra state lands in the right card with no release.- Known ceiling:
useDocumentsloads 200 rows, so counts undercount past that.
- One strip component and one card vocabulary for every list, so wording and tone are consistent.
- A page declares its cards as data; it writes no layout.
- Figures are correct for any list size (server-side where the list is paged), and money is exact
(decimal strings,
sumMoney; no floats). - Cards can be clicked to filter the table where the page supports filters (already the claims
pattern:
StatCardonClick/active).
Non-goals
Section titled “Non-goals”- Charts, trends and period comparison. Those belong to reports (which get headline cards only).
- Per-user customisable cards.
- Setup and reference pages (settings, sessions, webhooks, numbering, custom fields, grades, locations, asset classes, exchange rates, chart of accounts, channels, groups, members).
Design
Section titled “Design”1. Cards as data
Section titled “1. Cards as data”interface SummaryCard<Row> { key: string; // i18n: `summary.<key>` tone: StatTone; icon: LucideIcon; /** Which rows count. */ match(row: Row): boolean; /** What the secondary line shows: value of the matching rows, or something else. */ figure?: "money" | "age" | "none"; /** Optional: filter the table to these rows when clicked. */ filter?: string;}SummaryStrip<Row> takes rows, cards, currency, ns and renders StatCards in the shared
grid-cols-2 lg:grid-cols-N section. SemanticSummary becomes the default cards (the five stage
cards) so the trade lists do not change. A page that needs different cards passes its own array.
Rejected: one hard-coded component per page. It is what exists today and is what drifts.
2. Where the figures come from
Section titled “2. Where the figures come from”| List size | Source |
|---|---|
| Bounded, fully loaded (approval queues, trade lists up to 200) | Computed in the browser from the loaded rows. |
| Keyset-paged (AP and AR invoices, payments, receipts, payroll runs) | Server endpoint GET <collection>/summary returning the same shape as /advances/summary: per-bucket {count, total} in base currency, scoped by the caller’s read permission and the list’s own filters. |
The strip takes either rows or a ready summary, so a page moves from client to server figures without a layout change. The browser never adds figures from a partial page: where the list is paged and no summary endpoint exists yet, the strip is not shown rather than shown wrong.
Server summaries are one SQL aggregate per call, under the same RLS and permission checks as the
list. No new tables. Each endpoint is added to the OpenAPI spec first, then make generate.
3. Money
Section titled “3. Money”Totals are in the organisation’s base currency (net_base, or the document’s base amount), so rows in
different currencies add up. Where the amount is not in base currency and has no base column, the card
shows the count only; it never converts in the browser.
4. Pages and cards
Section titled “4. Pages and cards”Wording is plain nouns, matching the existing cards (“Pending”, “Approved”, “Paid”). Every label is added to en, zh and ms.
| Page | Cards | Source |
|---|---|---|
Supplier invoices (payables/InvoicesPage) |
Unpaid, Overdue, Due this week, Paid | server summary |
Customer invoices (receivables/InvoicesPage) |
Outstanding, Overdue, Paid | server summary |
Approvals (approvals/ApprovalsPage) |
Deferred: needs timestamps and base amounts in the queue payload (open question 1) | n/a |
Payroll runs (payroll/RunsPage) |
Draft, Approved, Paid (net pay total) | client (few rows) |
| Payments, receipts, remittances, batches | Draft, Pending, Posted (value) | server summary |
| Tax returns | Due soon, Overdue, Filed | client |
Lower priority, same pattern: bank statements (unmatched count and amount), e-invoice submissions (pending / accepted / rejected), assets (count, net book value), leave admin (pending), inventory (low stock, out of stock), vacancies.
Report pages
Section titled “Report pages”Reports already return their totals, so cards on them need no new endpoint: the strip reads
figures the page already has, and the table footer stays. A report passes ready figures instead of
rows (built as FigureStrip, which renders StatCards from ready figures).
| Report | Cards |
|---|---|
AR aging (reports/ARAgingView) |
Total outstanding, Overdue, Over 90 days (mirror of AP aging, which has cards) |
| Profit and loss | Revenue, Expenses, Net profit |
| Balance sheet | Assets, Liabilities, Equity |
| Trial balance | Total debit, Total credit, Difference (red when the books do not balance) |
| Budget vs actual | Budget, Actual, Variance (overspend in red) |
| Withholding tax register | Total withheld, Entries |
Operations reports (trade/ReportsPage) |
Open orders, Not invoiced, Outstanding |
Already have cards: AP aging, invoice analysis. No cards: general ledger and custom reports (detail listings with no single headline figure).
Card meanings that need a rule:
- Overdue: due date before today and not settled. Today is the organisation’s local date, not the browser’s.
- Due this week: due within the next 7 days, not yet overdue.
- Pending my approval: the count and value of the caller’s
to_actitems across the queues they may read; the card is hidden when they can read none. - Longest waiting: age of the oldest
to_actitem, shown as days (“5 days”; “Today” under a day). Age runs from the moment the item reached the caller’s step. If the API does not expose that, use the submission date and say so in the card’s tooltip. Open question until the queue payload is checked. - Approved today: items the caller approved since local midnight. Needs the decision log; open question whether the queues expose it, otherwise drop the card rather than add an endpoint for it.
5. Loading, empty and error
Section titled “5. Loading, empty and error”- Loading: cards show
–, never0. - Empty list: cards show
0and0.00; the strip still renders, so the page does not jump. - Error in the summary only: the strip shows
–and the table still loads.
6. Accessibility and layout
Section titled “6. Accessibility and layout”<section aria-label> wraps the strip; a clickable card is a button with aria-pressed (already
StatCard). Two columns on a phone, four or five from lg. No fixed heights.
7. Scale rules (every card must hold at these)
Section titled “7. Scale rules (every card must hold at these)”An audit of the built cards against growth in rows, currencies and text:
| Risk | Rule | Where |
|---|---|---|
| A list longer than the rows loaded | Counts are lower bounds (200+), money is left out, and a line says how many rows the figures cover. Never a confident wrong number. |
SummaryStrip partial; trade lists pass rows.length >= DOCUMENT_LIMIT |
| Rows in several currencies | Add only base-currency amounts. Without a base figure, drop the money card instead of adding unlike currencies. | trade lists (net_base), payroll, operations reports |
| Unbounded lists | Bounded by nature (payroll: one run a month) may count in the browser; anything paged needs a server summary. | section 2 |
| Report totals | Taken from the server’s own totals, never re-added in the browser (except the labelled Overdue and Difference subtractions). | FigureStrip |
| Many cards (a section per currency) | Cards wrap in an auto-fit grid and each has a unique key and a currency in its label. | FigureStrip, WHT register |
| Large amounts, long labels, zh and ms text | Card text wraps (break-words) instead of overflowing; labels are translated, none hard-coded. |
StatCard |
| New or renamed workflow states | Stage cards match on semantic, never a state key. | semanticCards |
Tests: SummaryStrip.test.tsx covers the grouping, the complete list and the cut-off list.
8. Clicking a card
Section titled “8. Clicking a card”A card is a way into the detail, never a dead figure.
- List pages (trade documents, payroll runs): clicking a card filters the table below to that card’s
rows; clicking it again, or the All card, clears it. The chosen card has the focus ring and
aria-pressed. The page keeps the key, the strip keeps showing the whole list’s figures. - Expense claims (server summary, already scalable): each summary row now carries a
bucket(pending, approved, paid, rejected) from the server, so a card and the chips agree. Approved is a claim past its workflow’s final approval (Definition.FinalApproval, where a request counts as approved: the Approved state, still pending-semantic until finance verifies the category) plus the accrual states; Pending excludes those. A card sets the page’sstatefilter to the states in its bucket; the list API’sstatetakes one key or several comma-separated. A card with no claims stays plain; the Submitted card is the total and stays plain. Advances work the same way: the shared rule lives inworkflow.FinalApprovalStates/FinalExpr/SummaryBucket, both summaries use it, the advances list gained the samestatefilter (one key or several, also onto_act), and its cards filter bybucket. Petty cash needs nothing: its cards are float balances (Available and so on), not approval stages, and have no state to filter by. - Report pages: a card that has a real destination goes there: AR aging Total outstanding to Sales invoices, trial balance Difference to Journal entries. Rendered without a router, a card stays plain rather than a button that does nothing.
- Not linked yet: figures whose destination cannot show the same rows. Overdue and Over 90 days
need the invoice lists to filter by due date, and those lists keep their filters in local state
(only
kindis in the URL). The fix is to putstateand adue_beforefilter in the list route’s search, then link the Overdue cards with it. Until then they stay plain, as does AP aging. - Cards on paged lists will filter through the same server query as the table, not in the browser.
Testing
Section titled “Testing”- Unit test per card set:
matchover a small row fixture (the pattern ofSemanticSummary.test.ts), including an org-added custom state and a multi-currency list. - Component test: loading shows
–; a clicked card sets the filter andaria-pressed. - Server: each summary endpoint has a test that its totals equal the list endpoint’s rows summed under the same filter, and that a member without read permission gets 403.
Rollout
Section titled “Rollout”- Extract
SummaryStripand card data;semanticCards()is its default (no behaviour change). Done. - Payroll runs (client figures, no API). Done. Approvals deferred, see open question 1.
- Supplier and customer invoices, with their summary endpoints.
- Report pages (no API). Done: AR aging, profit and loss, balance sheet, trial balance, budget vs
actual, withholding tax register and the four operations reports, all through
components/ui/FigureStrip(ready figures, nothing summed there; Overdue, Difference and the operations amount usesubtractMoney/sumMoney). The operations reports show an amount card only when every row is in one currency, since those rows carry no base figure. - Not built, separate PR. Supplier and customer invoices, payments, receipts, remittances and
batches need the server summary endpoints of section 2. Finding from reading the code:
receivablescomputesoutstandingper row from allocations (InvoiceOutstanding, one query per row), so the summary needs its own aggregate that reproduces that rule exactly, plus OpenAPI,make generate, the public-API baseline and handler tests. Tax returns can follow client-side. - The lower-priority list. Not built.
Each step is its own PR; a page ships only with its three locale files.
Open questions
Section titled “Open questions”Does the approvals queue payload carry timestamps?Answered: no.QueueItemhas no submitted-at, step-entered-at or decision time, and amounts are in each item’s own currency with no base value. So Longest waiting, Overdue and Approved today are dropped, and a money card would mix currencies. The approvals page already has per-kind count chips, so it gets no strip until the queue payload carries those fields.- Is “Overdue” fixed at the due date, or should an organisation set a grace period?
Click to filter on every page?Answered: yes where the page can show those rows; see section 8.