Skip to content

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.

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.

  • components/workflow/SummaryStrip.tsx: the generic strip. A page passes rows, an amount accessor and its cards ({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 into trade/DocumentsPage (all eight document kinds) and payroll/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: useDocuments loads 200 rows, so counts undercount past that.
  1. One strip component and one card vocabulary for every list, so wording and tone are consistent.
  2. A page declares its cards as data; it writes no layout.
  3. Figures are correct for any list size (server-side where the list is paged), and money is exact (decimal strings, sumMoney; no floats).
  4. Cards can be clicked to filter the table where the page supports filters (already the claims pattern: StatCard onClick / active).
  • 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).
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.

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.

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.

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.

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_act items across the queues they may read; the card is hidden when they can read none.
  • Longest waiting: age of the oldest to_act item, 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.
  • Loading: cards show –, never 0.
  • Empty list: cards show 0 and 0.00; the strip still renders, so the page does not jump.
  • Error in the summary only: the strip shows – and the table still loads.

<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.

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’s state filter to the states in its bucket; the list API’s state takes 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 in workflow.FinalApprovalStates / FinalExpr / SummaryBucket, both summaries use it, the advances list gained the same state filter (one key or several, also on to_act), and its cards filter by bucket. 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 kind is in the URL). The fix is to put state and a due_before filter 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.
  • Unit test per card set: match over a small row fixture (the pattern of SemanticSummary.test.ts), including an org-added custom state and a multi-currency list.
  • Component test: loading shows –; a clicked card sets the filter and aria-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.
  1. Extract SummaryStrip and card data; semanticCards() is its default (no behaviour change). Done.
  2. Payroll runs (client figures, no API). Done. Approvals deferred, see open question 1.
  3. Supplier and customer invoices, with their summary endpoints.
  4. 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 use subtractMoney / sumMoney). The operations reports show an amount card only when every row is in one currency, since those rows carry no base figure.
  5. 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: receivables computes outstanding per 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.
  6. The lower-priority list. Not built.

Each step is its own PR; a page ships only with its three locale files.

  1. Does the approvals queue payload carry timestamps? Answered: no. QueueItem has 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.
  2. Is “Overdue” fixed at the due date, or should an organisation set a grace period?
  3. Click to filter on every page? Answered: yes where the page can show those rows; see section 8.