Skip to content

Plan 10 — Frontend: design

Authority: the architecture spec (2026-09-20-erp-architecture-design.md) §13, with §5.5 and §12.2 where they touch the UI, and §11A.3 for locales. This document fixes what the spec leaves open and records every departure, with the reason.

The user delegated every design decision on screens the Figma file does not cover. The bar they set is “user friendly”. They did not review this document or its plan before the build, so a later reader should treat the rulings below (F1–F21) as mine and open to challenge.

  • web/ is empty. The only TypeScript in the repo is packages/contracts/vocab.ts, generated by tools/vocabgen.
  • api/openapi.yaml is the contract. Authentication is a bearer access token (short-lived, from login, refresh or switch-org) plus an HttpOnly, SameSite=Lax refresh cookie. There is no CORS, so the browser app must be served same-origin with /v1.
  • Figma file Uz1j7iSQYOYdCLKTm6bRsT (Mcom (3)). I read these frames through the Figma MCP:
Node What
123:7183 Claims list (我的报销): KPI cards, scope tabs, status chips with counts, search, project and month filters, table
278:24022, 286:9929 Claim detail drawer, closed and pending. Prev/next pager, receipts, basic info, progress timeline, comments, approve/reject bar
233:13631, 166:12082, 166:11488 Submit-claim wizard, 3 steps: info → amount & receipts → payout method & confirm
407:17221 Petty cash page: KPIs, “to approve” table, per-project usage, movement ledger
426:18791, 446:20155 Request float (申请备用金) and return float (归还备用金) dialogs
477:16079 Dark mode (English copy)
426:24658, 94:27703 1280px small-screen layout; 390px mobile claims
0:1 Components page

Figma variables (read with get_variable_defs): Dark purple #6a4b8d, light purple #f1e9f1, outline #d3d2dc, light outline #efeef2, Color #8e8e93 (muted text), White, black. The type ramp is Roboto: Caption 12/14, Body copy 14/16, Sub bold 14/16 w700, Sub title 16/20 w500, tracking 0.5.

The design is purple, not the “dark navy” §13.2 describes. I follow the design (F1).

Plan 10 delivers the four roadmap lines under “Plan 10 — Frontend”:

  1. Shell, org switcher, i18n. Locales are en, ms and zh, not the roadmap’s en / zh. ms is statutory (§11A.3), and §13 already lists all three. The roadmap line is corrected when it is ticked (F2).
  2. Claims list and detail, from Figma.
  3. Workflow-driven UI: status chips, filter chips and action buttons all come from the API.
  4. The §13.5 screens Figma lacks, in the spec’s order: workflow definition editor, payout channel editor, chart of accounts, journal entries and GL, fiscal periods and close, reports, advance acquittal, float register.

It also delivers the screens a user needs in order to use items 2–4 end to end, even though no roadmap line names them. Without these, the claims screens are a demo:

  • Login, invitation acceptance, the session list (sign out other devices).
  • My payout details (管理收款资料), which the wizard’s step 3 links to.
  • Petty cash from Figma: the float page, request and return.
  • Advances list and detail (no Figma; built from the components).
  • Delegations (my out-of-office delegate).
  • Team (团队 in the Figma nav): members, invitations, roles and permissions, and org structure (units, positions, who holds them). Without these, a new organization can’t be made usable from the browser. Only the Owner role is seeded with grants (provisioning/steps.go, seededRoles), so an invited Employee holds no permission until an administrator grants one. And approval routing needs positions (the no_primary_position warning). Today both need raw SQL or API calls (F19).

Out of scope, and deferred with a reason:

  • Claim comments (评论). Figma shows a thread. No comment model exists, and adding one means a new record-bearing table with retention, locale and audit rules. The drawer shows the decision log’s reasons in its place (F3). This is a candidate for Phase 2.
  • Global search, AI chat, notifications bell, Home/Projects/Asset booking/Announcements nav items. No backend exists. The top bar keeps the layout, but these controls are not rendered until a backend exists. Nav items are rendered only for modules the API serves (F4). A dead button is less friendly than a missing one.
  • Company profile editing. There is no API (Plan 2 note), and the profile is set at provisioning.

The design needs data the API does not expose. Every change is additive to api/openapi.yaml. Nothing is renamed and no response loses a field. The generated TypeScript client proves the shapes. Migration range 00170–00189 and provisioning steps 130–139 are reserved for Plan 10. Only S7 needs a migration: the one-shot grant of org.roles.manage to existing Owner roles. Every other addition reads columns that already exist.

# Change Why
S1 GET /v1/me: user id, email, display name, member id, current org (id, name, base currency, country), organizations[], permissions[] (strings from vocab) The UI must know who is signed in and which nav items and actions to show. The token carries only ids and a permission version. Listed in [no-permission]: it reads only the caller’s own identity, taken from the signed claim, like switch-org
S2 GET /v1/org/members: directory of the org’s members {member_id, name, active} Claims, advances and decisions carry member ids only, and the timeline and pickers need names. It is listed in [no-permission], with this argument: it exposes only names and ids of the caller’s own organization (RLS-isolated), the same names any member already meets on the records they can read. Gating it would leave every non-Owner role (zero grants at seed) seeing raw uuids
S3 GET /v1/expenses/claims gains state, q (reference or title, case-insensitive prefix/contains), month (YYYY-MM, on created_at), plus created_at, submitted_at (nullable; the first decision out of draft) and category_ids[] (distinct active-line categories, line order) on ExpenseClaim The table’s filters and columns. submitted_at comes from the decision log, not a new column
S4 GET /v1/expenses/claims/summary?scope=&month=: [{state, semantic, count, total}] for the caller’s scope KPI cards and filter-chip counts. Counting a page client-side would be wrong once there is more than one page
S5 scope=to_act on GET /v1/pettycash/spends and GET /v1/pettycash/topups The petty-cash “to approve” table (待审批). It mirrors claims and advances, which already have it
S6 GET /v1/advances/summary, as S4 for advances The same KPI treatment on the advances page
S7 Roles: GET /v1/org/permissions (the registered catalogue), GET/POST /v1/org/roles, PATCH /v1/org/roles/{id} (rename), PUT /v1/org/roles/{id}/permissions (replace the set), PUT /v1/org/members/{id}/roles (replace a member’s roles). A new permission org.roles.manage guards all of them, and it is added to the Owner seed There is no role editor API (roadmap, Plan 2). Every change is audited with its before and after sets, and bumps the affected members’ permission version so their next token refresh sees it. The Owner role’s org.roles.manage cannot be removed, and a member cannot remove their own last role that holds it. Both are refusals, not warnings, because either change locks the organization out (§4.2). Existing organizations’ Owner roles get org.roles.manage from a one-shot migration, as DEPLOY_NOTES.md did for the pettycash.* permissions

Each change gets its handler test, its routes-permission test entry, and presence-then- absence tests where it filters: a state filter must be shown to return a claim before it is shown to exclude one.

Stack as §13.3: React 19, Vite, TypeScript strict, TanStack Router (file-free, code-defined routes) and TanStack Query, TanStack Table, Radix primitives styled with Tailwind v4 in the shadcn pattern (components copied into web/src/components/ui, not a runtime dependency), zustand for UI-only state, i18next.

TanStack Router over React Router (F5): typed search params carry list filters (?scope=to_act&state=pending&month=2026-08). That makes every filtered view a shareable URL with no hand-written parsing.

web/
index.html vite.config.ts tsconfig.json package.json Dockerfile
src/
app/ router.tsx, providers.tsx, shell/ (Sidebar, TopBar, OrgSwitcher, UserMenu)
features/
auth/ claims/ advances/ pettycash/ approvals/ payout/ workflow/
accounting/ reports/ settings/
components/
ui/ Button, Dialog, Drawer, Select, Tabs, Table, Toast, Tooltip… (Radix + tokens)
workflow/ StatusChip, StateFilterChips, TransitionBar, DecisionTimeline, ReasonDialog
money/ MoneyText, MoneyInput
upload/ DocumentUploader (presigned PUT, complete, progress)
lib/
api/ schema.d.ts (generated), client.ts, problem.ts, auth.ts
i18n/ index.ts, en.json, ms.json, zh.json
money.ts string-decimal format/validate — never Number()
date.ts Asia/Kuala_Lumpur display, ISO on the wire
styles/tokens.css

openapi-typescript generates web/src/lib/api/schema.d.ts from api/openapi.yaml, and openapi-fetch provides the typed calls (F6). There is no hand-written request or response type anywhere. make generate runs the generator, and the Go drift test that already proves generated code matches its source is extended to cover schema.d.ts. So a spec change that is not regenerated fails CI.

client.ts adds the middleware:

  • Authorization: Bearer <token> from memory. The access token is never written to localStorage (F7). A page load re-mints it through POST /auth/refresh, which uses the HttpOnly cookie.
  • On 401, one refresh followed by one retry, single-flight so that ten concurrent 401s cause one refresh. A refresh that fails goes to /login?next=.
  • If-Match from the resource’s revision on writes that need it. Idempotency-Key (UUID per user action, reused on retry) where the spec requires it.
  • application/problem+json is parsed into a typed Problem. Field errors map onto form fields. 409 revision conflicts show “this changed while you were editing — reload” with a reload action, never a silent overwrite.

The invariant is that a float never touches money. The wire is a string, and so is the UI:

  • lib/money.ts formats a decimal string with grouping and the currency, by string manipulation ("1234.5" → "RM 1,234.50"). Display uses the currency’s minor units (2 for MYR), rounding the string half-up, which only ever happens for display.
  • MoneyInput keeps a string, accepts ^\d+(\.\d{0,4})?$, and sends it unchanged.
  • The UI never adds amounts. Every total (claim total, KPI totals, float balances) comes from the API.
  • Drift test (Vitest, in CI): scan web/src for parseFloat, Number(, +amount unary on money-named identifiers, and toFixed. Allowed only in lib/date.ts and pagination code, by explicit allowlist. Following AGENTS.md, it first asserts that it scanned more than 0 files and found the patterns in a planted fixture. It is watched failing once.

i18next with en, ms and zh bundles. Choosing the locale, in order: the user’s choice (stored in localStorage, UI-only), the browser, then en. Every UI string is a key, and a Vitest drift test fails when a key exists in one bundle and not another.

Record text is not UI text. Server-supplied labels (workflow state and edge labels, category names, channel names) come back per locale from the API. The UI picks labels[uiLocale] ?? labels.en. Free-text inputs that land on records (claim title, line description, reasons) show a hint when the UI locale is zh: record text must be English or Malay (§11A.3). The server’s 422 is still what decides; the UI does not pre-validate script (F8).

styles/tokens.css declares the Figma variables as CSS custom properties, mapped into Tailwind v4 @theme. There is no hex outside tokens.css, and a Vitest drift test enforces that. Dark mode follows frame 477:16079 and is a [data-theme=dark] override of the same tokens. The default follows prefers-color-scheme, with a toggle in the user menu.

Status colours follow the Figma chip set and are keyed on semantic, never on state key (F9). WorkflowState has no colour field, and inventing one would be a schema change for styling. The mapping:

semantic tone figma
draft neutral grey outline
pending amber 待审核
accrual green 已批准
payout green (solid) 已付款
acknowledged teal 已结案
dispute purple —
rejected red 已拒绝
cancelled neutral-muted —
custom blue 已审核

A tenant who adds a custom step gets a correctly-labelled blue chip with no release. That is the §13.4 promise.

The shared components under components/workflow/ are the only place workflow state is rendered.

  • useWorkflowDefinition(kind) reads GET /workflow/definitions/{kind} (the pinned version, cached for the session). A list with rows on several versions fetches each version it sees once.
  • StatusChip({kind, version, state}) shows the label from the definition, with the tone from the state’s semantic.
  • StateFilterChips is built from the definition’s non-terminal and terminal states in display_order, with counts from the S4/S6 summaries. “All” comes first. A state with no rows still shows 0, so the layout does not jump.
  • TransitionBar({available_transitions}) renders one button per exit, in edge display_order. The primary style goes to the first allowed non-reversing edge, and danger style to edges whose target is rejected or cancelled. A disallowed transition is shown disabled with its refusal.detail as a tooltip (§13.4: “including the reason a disabled action is disabled”). A requires_reason edge opens ReasonDialog first. The frontend never evaluates a gate or a threshold.
  • DecisionTimeline is built from decisions[] (oldest first), then from the definition’s remaining states after the current one, rendered as pending steps. Figma’s “报销进度 (n/m)” is the count of distinct states reached over the states on the main path. The main path is the definition’s states in display_order, excluding terminal reject/cancel semantics. Actor names come from S2. The resolved eligible approvers of the current step are shown as “等待 X 审批” when the instance view carries them.

For each screen, F means built from a Figma frame and C means composed from the Components page because Figma has no frame for it.

  • Sidebar: the org switcher at the top (POST /auth/switch-org, then invalidate every query), then the grouped nav: 工作区 / Workspace (Activity), 申请 / Requests (Claims, Advances, Petty cash), 审批 / Approvals, 管理 / Manage (Accounting, Reports, Workflows, Payout channels, Settings). Every item is gated on me.permissions. A group with no visible items is hidden.
  • Top bar: breadcrumb, then the user menu (locale, theme, my payout details, sessions, sign out).
  • Responsive: at ≥1280px the full sidebar shows, per the small-screen frame. From 768 to 1280px the sidebar collapses to icons. Below 768px the sidebar is a drawer, lists become cards (frame 94:27703), and drawers go full-screen.
  • Login (C): email and password, and the org picker when there is more than one org. Accept invitation (C): the token from the URL, then name and password.
  • Activity (C): GET /activity, as an infinite list.
  • List (/claims): the header “My claims”, with a “N claims this month · M pending” subline built from S4. KPI cards (pending / approved / paid / total submitted) are bucketed by semantic: pending = pending + custom; approved = accrual; paid = payout + acknowledged; total = everything except draft. The tabs are My claims (scope=mine), My approvals (to_act) and All claims (all, only with expenses.claim.read_all). Then state chips, search, a group (project) select, a month select, and export. The table columns are title (with the first line’s supplier as the subline), category pill (the first category, then “+N”), status chip, amount, submitted, reference, and an overflow menu (open, copy reference, withdraw draft). Keyset pagination loads 10 rows by default, per the design note on 426:21251, with “Load more”, because the API is cursor-based (F10).
  • Detail drawer (/claims/$id): the drawer opens over the list, and the URL is the claim id. ‹ n/N › steps through the current list without closing, per the design note on 297:15745, with keyboard ←/→ and a sticky header. It shows: title, reference, submitted time, status; an amount banner (when the caller may act: “当前待办 · ” plus the amount); the TransitionBar; receipts (thumbnails through GET /documents/{id}, zoom, download); basic info (group, categories, dates, payout channel, masked); lines (Figma shows one amount; claims have lines, so a compact line table is added, F11); duplicate-receipt flags with the matching references (possible_duplicate); the DecisionTimeline; and settlements.
  • Submit wizard (/claims/new, a dialog), following Figma’s three steps and mapped onto the API (F12):
    1. Info: group (项目), line type, category, title, description. “Save draft” is POST /expenses/claims on first save, and a PATCH after.
    2. Amount and receipts: the upload (POST /documents for a presigned PUT, PUT to the object store with a progress bar, then POST /documents/{id}/complete), date, amount, currency (FX shown read-only from the server’s line response), and a note. It creates or updates one line and attaches the documents.
    3. Payout and confirm: payout-method cards from GET /payout/me/profiles, masked, with “Manage payout details” going to §5.6, and a summary. Submit takes the submit transition from available_transitions, never a hard-coded key.
    • “Add another line” on step 2 is a secondary action, for multi-receipt claims. The single-line path stays the default because that is what the design optimizes for.
  • Edit a draft reopens the same wizard at step 1 with the draft loaded.

/approvals is a combined queue: claims (to_act), advances (to_act), petty cash spends and top-ups (to_act, S5). Each row opens its own drawer, with the TransitionBar and ‹ › to step through the queue. The sidebar badge shows the queue count, polled every 60 s and on window focus.

  • Page (/petty-cash): KPI cards. Figma’s “authorized project float total” has no backing concept, because floats are per holder, not per project. So the cards are pending (my spends and top-ups awaiting a decision), cash balance and available of the selected float, with a committed/balance bar. The selected float is my own; a custodian with pettycash.read_all picks one from a select. There is no cross-float total, because it would be a client-side money sum (F13). Below them: the “to approve” table (S5) with inline approve/reject from available_transitions; Floats in place of “by project” (code, holder, balance, committed, available); and the movement ledger (GET /pettycash/floats/{id}/movements) with type pill, signed amount, status and reference.
  • Request float (申请备用金) → POST /pettycash/topups. Return float (归还备用金) → a close-out with refund proof (POST /pettycash/floats/{id}/closeouts, then the upload of proof), following frame 446:20155. Record a spend (C) → POST /pettycash/spends, then the receipt upload.

A list and a detail drawer, with the same components as claims. The detail shows the due date, overdue level (AdvanceOverdue), legs, and the acquittal panel (§13.5 #7). The panel shows advanced vs. spent, then the signed variance and the outcome it implies (top-up to employee, refund to company, or exact), then the tax record fields and evidence upload. The variance and outcome come from the acquittal view; the UI never computes them.

  • My payout details: GET /payout/me/channels field schemas, rendered by a small field_schema form renderer (text, select, pattern). Values are masked on read.
  • Sessions: list and revoke.
  • Delegations: list, create (delegate, window, optional kind, reason), revoke.

5.7 The §13.5 screens (C), in the spec’s order

Section titled “5.7 The §13.5 screens (C), in the spec’s order”
  1. Workflow definition editor (/admin/workflows/$kind). The published version is read-only, and there is an Edit draft action (PUT /workflow/drafts/{kind}). The layout is a left state list (drag to reorder display_order; semantic select; terminal toggle; labels in en/ms/zh), a centre graph (a read-only auto-laid-out diagram of states and edges using elkjs layered layout rendered as SVG; click to select), and a right inspector for the selected state or edge: edge gate, action_key, requires_reason, reverses_accrual and labels. For a state it shows the grants table: grantee kind, grantee picker (member / position / levels up), a min_amount floor, vacancy behaviour, and a live tiling preview of the bands built from the floors. Violations from the draft response are shown inline: a banner lists them, and each ref highlights its state or edge in red. Publish is disabled while there are violations, and the server still decides. Routing preview (GET /workflow/definitions/{kind}/routing) shows who would approve an amount at each step, and flags gaps (F14). Drag-editing the graph canvas itself is deliberately not built. The list plus inspector edits everything, and a canvas editor is the most expensive, least accessible option (F15).
  2. Payout channel editor: a channel list, and an editor with name labels, kind, active, GL account binding (an account picker filtered to eligible control kinds), and a field_schema builder (rows of key, type, label per locale, required, pattern), with a live preview using the same renderer as §5.6.
  3. Chart of accounts: a tree (header/posting accounts, control kind badge), an account editor, and module-owned accounts shown locked, with their owner.
  4. Journal entries and GL: an entry list (journal, period, reference), entry detail with lines and analytics, a manual journal form (balanced-lines helper showing the difference as a server-returned 422 plus a live string-decimal difference, F16), and reversal (POST /accounting/entries/{id}/reversal, with reason).
  5. Fiscal periods and close: fiscal years with their periods, status, and close/reopen. The close dialog first runs GET /accounting/reconciliation and shows each check pass/fail. Close is enabled only when the server’s close call would accept, and the server still decides.
  6. Reports: trial balance, P&L, balance sheet and GL detail, with period/date pickers, an expandable account tree, and Export (CSV / XLSX / PDF) through POST /reports/exports, polling status and then downloading. Aged advances and the float register are shown only if their endpoints exist; they are deferred in Plan 9 (D1/D2).
  7. Advance acquittal: §5.5.
  8. Float register and reconciliation: the float list with open/closing/closed status, the movement ledger per float, opening a float, and close-out review.
  • Members: the directory (S2), with each member’s roles and primary position, and an invite button (POST /org/invitations, role picker). A pending-invitations tab supports revoke (DELETE /org/invitations/{id}).
  • Roles: a role list, and a permission matrix grouped by module (from GET /org/permissions), with preset buttons (F20). Saving goes through S7. The two lockout refusals show as inline errors.
  • Org structure: a units tree and positions (reports_to) from /org/chart, create and edit dialogs, and assigning holders (/org/members/{id}/positions). The /org/readiness warnings (for example no_primary_position) are shown at the top as a checklist, with a link to fix each one. This is the screen an administrator lands on first in a new organization.
  • Problem details: the title is shown as a toast, and errors[]/field pointers go inline on forms. 403 shows “you don’t have permission” (the nav should have hidden the way there). 404 shows “not found or not visible to you”, worded the same way for both on purpose (R21). 409 revision conflicts are covered in §3.1. 429 shows the retry time.
  • Network or 5xx: TanStack Query retries reads twice with backoff. Writes never auto-retry, except with the same Idempotency-Key where the endpoint supports it.
  • An error boundary per route shows a friendly panel with a reload button and a request id (from the X-Request-Id response header) so users can report it.
  • Unit and component (Vitest + Testing Library + MSW): money formatting (including large values beyond 2^53, which must format exactly), the token refresh single-flight, problem mapping, StatusChip tone by semantic, TransitionBar (disabled plus refusal tooltip, requires_reason opens the dialog), DecisionTimeline progress count, wizard step gating, the field_schema renderer, and the workflow editor’s violation highlighting.
  • Drift tests (Vitest): no float on money, no hex outside tokens.css, i18n key parity across en/ms/zh, and the generated client matching the spec (the Go test). Each is watched failing once, and each asserts a non-zero scan first.
  • End to end (Playwright): against the real API on a testcontainers-backed dev stack (make dev-stack: Postgres, migrations, a seeded demo org via orgadmin create-org plus a seed script, and api + worker + vite). The flows are: log in → file a claim with a receipt → submit → second user approves → verify → pay, with the status chip and timeline checked at each step. Also: a petty-cash spend round trip, publishing a workflow draft with a violation (it must show the violation inline) and then fixing it, and running a trial-balance export. Playwright runs locally and in a separate CI job (Node + Docker), not inside make test, so the Go suite is untouched.
  • Manual verification: every screen checked in the in-app browser at 1440, 1280 and 390 widths, in light and dark, and in en and zh, before its task is marked done.
  • web/package.json uses pnpm (installed on the dev machine), Node 24, and a lockfile.
  • CI gets a new web job: pnpm install --frozen-lockfile, pnpm typecheck, pnpm lint (ESLint + Prettier check), pnpm test, pnpm build. Playwright is a separate job that needs Docker.
  • Serving: web/Dockerfile builds static assets into an unprivileged nginx image that serves / with an SPA fallback and proxies /v1 to the api service. That keeps the app same-origin with no CORS and no change to the Go router (the [outside-v1] section of public_routes.txt stays empty). The deployment’s TLS reverse proxy points at this container. DEPLOY_NOTES.md gets the Compose snippet and the note that the refresh cookie’s path must match the public origin (F17).
  • Dev: vite proxies /v1 to localhost:3000. .claude/launch.json gets a web configuration for the in-app preview.
  • Content Security Policy: default-src 'self', with img-src also allowing the object store’s public origin for presigned thumbnails. It is set in the nginx config (F18).
  • F1 Follow the Figma purple, not §13.2’s “dark navy”.
  • F2 Ship en/ms/zh, and fix the roadmap line.
  • F3 No comments. The decision-log reasons take their place in the drawer.
  • F4 Hide nav and top-bar controls that have no backend.
  • F5 Use TanStack Router, for typed search params.
  • F6 Use openapi-typescript + openapi-fetch, with drift-tested generation.
  • F7 Keep the access token in memory only, restored by the refresh cookie.
  • F8 Show the record-language hint, and let the server decide.
  • F9 Status tone is keyed by semantic; no colour column.
  • F10 Cursor pagination, 10 by default, with “Load more”.
  • F11 Show a line table in the claim detail.
  • F12 The wizard maps to claim + one line + documents + the submit transition.
  • F13 No client-side money sums.
  • F14 Include routing preview in the editor.
  • F15 No drag-edit canvas; use the list plus inspector.
  • F16 Show the manual journal’s difference by string-decimal subtraction in lib/money.ts. This is the single place the UI does arithmetic on money: exact decimal on strings (a BigInt of minor units), never a float, and covered by the drift test’s allowlist and its own unit tests. The server’s balance trigger remains the authority.
  • F17 The nginx container serves the web app and proxies /v1.
  • F18 The CSP is set at nginx.
  • F19 Team screens (members, invitations, roles, org structure) are in scope; see §5.8.
  • F20 Role presets (“Employee”, “Approver”, “Finance”, “Admin”) exist only as a UI helper that pre-ticks checkboxes in the role editor. Nothing is saved until the administrator saves, and the server’s seeding is unchanged.
  • F21 The member directory (S2) carries no permission, as argued in §2.